diff --git a/docs/Bots.md b/docs/Bots.md
new file mode 100644
index 000000000..798398c1a
--- /dev/null
+++ b/docs/Bots.md
@@ -0,0 +1,257 @@
+# Server-side AI Bots
+
+Bots are persistent, autonomous characters which populate a server like real
+players: they hunt with the skills of their class, level up, spend their points,
+keep their buffs up, pick up and wear better gear, restock in town, group up,
+defend themselves, and come and go over the day. A player who meets one should
+not be able to tell it apart from a quiet human player.
+
+They are driven entirely by the server. No game client is involved and no
+packets are exchanged: a bot is a connection-less `OfflinePlayer` — the same
+class which keeps a character playing after its owner logs out — with a
+navigator on top which gives it a life of its own.
+
+The feature is disabled by default. Enabling it is always a deliberate act of
+the server admin.
+
+## How it works
+
+**Bots are ordinary accounts.** Each one is a regular `Account` with the `IsBot`
+flag, holding up to five characters with generated names, levels, classes,
+stats, skills and starter gear. They are created once, saved like any other
+character, and reloaded on every start — a bot's progress belongs to the
+server's data, not to a process. Every bot animates one character in its own
+persistence context, so the characters of one account can play at the same time.
+
+**Two ticks make up the mind of a bot.** The offline MU Helper AI runs twice a
+second and does what it does for a human's offline session: attack, heal, buff,
+pick items up. On top of it, a bot navigator runs every second and decides the
+things an offline session never had to: where to hunt, when to travel or warp,
+when to go shopping, whom to follow. Everything a bot changes about itself —
+equipping, jewels, resets, master points — is queued into the AI tick, so it
+never runs while the combat handler is working on the same character.
+
+**Bots act through the regular player actions.** Moving an item, talking to a
+merchant, consuming a jewel, entering an event: a bot goes through the same
+actions with the same validations a client's packet would trigger. It cannot do
+anything a player could not do, and rule changes apply to bots for free.
+
+**The population is split over the game servers.** Bots count towards the player
+count of their server exactly like players do, and a server which reached its
+maximum player count turns new clients away — so a population large enough to
+fill a server would lock the players out of it. `Bot capacity %` (60 by default)
+is the share of a server's player limit its bots may occupy; the rest stays
+reserved for the players. Which accounts a server animates is a pure function of
+the account index and the set of configured game servers, so every server
+computes the same split without asking the others — which also holds when each
+game server runs as its own process. Exactly one server generates the
+population, so accounts and character names are never created twice. Accounts
+which do not fit stay offline until the deployment offers the room for them.
+
+## Configuration
+
+The *Bots* feature plugin, in the "Feature Plugins" section of the admin panel:
+
+- **`Enabled`** — spawns the bots after the server has started. Off by default.
+- **`Number of accounts`** — how many bot accounts to maintain.
+- **`Characters per account`** — how many characters each account animates at
+ once (at most five).
+- **`Bot capacity %`** — the share of a game server's maximum player count its
+ bots may occupy; the rest is reserved for the players.
+- **`Presence rotation`** — bots log in and out over the day instead of all
+ being online around the clock.
+- **`Min. online share %`** — how much of the population stays online at the
+ quietest hour.
+- **`Bots pay reset costs`** — whether bots pay the configured zen and item
+ costs for their resets. Off by default: they take no part in the economy those
+ costs are balanced for.
+- **`Reset bots`** — purges and regenerates the whole population on the next
+ start, then clears itself.
+
+## What a bot does
+
+### Hunting and travelling
+
+A bot hunts where the monsters actually are: it scans its surroundings for live
+monsters instead of walking to a spawn point which may be empty. Long distances
+are covered with a cached route over the whole map, walked a few steps at a
+time, so the bot can stop and fight on the way.
+
+It only engages what it can survive. The decision is made against the monster's
+real damage, defense and attack rate versus the bot's own defense, health and
+chance to be hit — a monster's nominal level says little about its punch on the
+high-end maps. An agility build's dodge therefore counts as the defense it
+really is, and better gear opens tougher maps, exactly like for a player.
+
+Map access follows the game's warp list: a bot enters a map only if its level
+may legally warp there, and a bot which finds itself on a map it may not be on
+(after a reset, for instance) leaves for the best map it may use. The map it
+reached is persisted, so a restarted bot wakes up where it stopped.
+
+### Fighting and progressing
+
+A bot fights with the strongest skill of its class it has learned and can pay
+for; casters keep their distance and drink mana. Skills are learned against the
+game's own requirements — total energy, leadership, character level — at
+generation and again on every level-up, and the class buffs are kept up on their
+own.
+
+Level-up points follow a per-class build modelled on what players actually play:
+an agility/shield meta on reset servers, guide-style builds on classic ones,
+chosen automatically by whether the reset feature is configured. Classes with
+two viable archetypes (a warrior or a wizard Magic Gladiator, a pure or an
+energy Blade Knight) roll one per bot, and a stat which hits a server's maximum
+overflows into the rest of the build.
+
+Bots evolve like players do. The second-generation class change happens at level
+200 — the same assignment the class-change quest performs — and the master class
+at the game's maximum level, followed by a relog, because the master attributes
+only mount when a character enters the world. Master points go into the master
+skill tree through the regular action, with its rank gates and skill
+requirements, preferring passives which boost a stat and strengtheners of skills
+the bot actually uses; a bonus tied to a weapon type the bot does not fight with
+is never bought. On a server with the reset feature, a bot only masters once its
+reset limit is exhausted — while resets remain, resetting is what players do, so
+the bots do it too.
+
+A mastered bot changes what it hunts. Master experience is only granted for
+monsters of at least `Minimum monster level for master experience` (95 in the
+default configuration), and a character at the maximum level earns nothing
+else — so below that line a kill pays a mastered bot nothing at all. It
+therefore looks for maps which hold such monsters, and takes the weakest ones
+above the line rather than the strongest: master experience hardly grows with
+the monster's level, so the cheapest kill above it is the best one. Those
+monsters carry 40.000+ health, well beyond the hit budget a bot's usual gear
+affords, so the budget is stretched for them — a slow fight it survives beats a
+quick one worth nothing. What is not stretched is its survivability: a monster
+whose hits the bot cannot take is refused, mastered or not.
+
+### Items and money
+
+Dropped gear is judged before it is picked up: a bot collects what it can wear
+and what is worth money, and leaves the rest lying. An upgrade is put on through
+the regular move-item action, with the whole swap planned first — which slot,
+and which pieces have to come off, including the other hand for a two-handed
+weapon. If the engine refuses the equip after all, the old gear goes straight
+back on. The replaced piece stays in the backpack and is sold on the next trip
+to town, rather than being dropped where the next bot would pick it up again.
+
+When the backpack fills up or the potions run low, the bot walks to a merchant,
+sells its junk, and buys refills from the proceeds while the shop dialog visibly
+occupies it — on a map without a merchant it warps home first, like a player
+would. Looted Jewels of Bless, Soul and Life are spent on its own equipment
+through the regular consume action, with the same success rates and failure
+penalties a player faces, and with the caution a player shows: a Soul is only
+risked where a failure cannot destroy the item's level.
+
+Wings do not drop, so bots earn them at the classic milestones instead — the
+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.
+
+### Mini game events
+
+A bot never enters Blood Castle, Devil Square or Chaos Castle on its own — it
+has no ticket and does not farm for one. It enters when a player who leads a
+party with bots enters with their own ticket: the leader's entry legitimizes the
+visit for the whole group.
+
+Each bot is checked against the entry restrictions a player faces (the level
+bracket, including the separate one for the special characters, the master-class
+requirement, the player-killer rule). A bot which does not qualify leaves the
+party and goes back to its own life instead of blocking the entry.
+
+Inside, its open-world routine is suspended: no shopping, no map changes, no
+boredom, no grudges. It fights what the event throws at it and keeps up with the
+leader. Chaos Castle is a free-for-all, so there the other participants are
+targets like everyone else — and a fight inside leaves no grudge outside. A bot
+which dies respawns in the safezone like a player, which takes it out of the
+event; the survivors are warped out when the event ends.
+
+### Company and rhythm
+
+Bots hunt in parties of two to five, grouped by level so the whole party can
+hunt the leader's maps. The elf heals, the buffs are shared, the party
+experience bonus applies. Parties re-form every hour.
+
+A player may invite a bot into their own party: it accepts after a human-like
+pause of a few seconds, provided the level gap is sane and it is not in the
+middle of an errand. A living player takes precedence over the bot's own company
+— a bot hunting with other bots leaves them for the inviter, and breaks that bot
+party up if it was leading it, so a player never has to guess which bot happens
+to be free. In a party the bot follows its leader, defers a due reset, and
+eventually leaves politely: when the leader enters a map it may not access,
+before its own logout, or simply when it gets bored.
+
+A bot fights back when a player attacks it, but only as far as the game's own
+PvP rules allow: inside the active self-defense window, or against a player
+already flagged as a killer. It can therefore never be provoked into becoming an
+outlaw that players could farm for free. It remembers who hit it, and a killed
+bot walks back to its killer — waiting for a legal opening rather than taking
+one.
+
+Over the day, the presence rotation logs bots in and out: fewest in the early
+morning, most in the evening, and never more than one at a time, so the
+population ebbs and flows instead of appearing and vanishing in blocks.
+
+### Keeping itself alive
+
+The engine's attribute system is not thread-safe, and a lost race can corrupt a
+character's attribute graph for good. A bot which hits it stops playing and
+throws on every following tick. Rather than leave it lying there, a bot counts
+the ticks which fail in a row and, after twenty of them, has itself restarted: a
+fresh login rebuilds the attribute graph and heals it — the same thing a player
+would do. A single failing tick is skipped, as before.
+
+## What it costs
+
+Measured on a 12-core host, as a rough guide for capacity planning:
+
+| Population | CPU | Memory |
+| --- | --- | --- |
+| 250 bots | ~0.35 core | ~760 MB |
+| 1100 bots | ~1.7 cores | ~1.2 GiB |
+
+Generating a fresh population costs about a second per account (the password
+hash dominates); starting an existing one of 1100 bots takes some 15 seconds.
+
+## Known limitations
+
+- **The engine's races are hit more often.** Neither `MagicEffectsList` nor
+ `ComposableAttribute` is thread-safe, and a thousand bots run into them more
+ often than human players do: a few caught exceptions per minute. A bot whose
+ attribute graph gets corrupted restarts itself (see above); a real fix belongs
+ into the engine, not into the bots.
+- **Master skills which cost ten points at once are never learned.** A bot
+ invests every point as it earns it, so it never holds ten of them, and the
+ branches of the tree behind such a skill stay untouched.
+- **The Summoner's enemy debuffs (Sleep, Weakness, Innovation) are unused.**
+ Deliberate: they would be cast through the buff rotation, which would have the
+ bot put itself to sleep. A cast-on-enemy path in the combat handler would be
+ needed.
+- **Bots never buy equipment.** They wear what they find, so their gear lags
+ behind their level, and a bot at the maximum level is weaker than a player of
+ the same level would be. It is the reason a mastered bot needs a stretched hit
+ budget to reach the monsters which pay master experience at all. Letting bots
+ spend their money on gear would close the loop; they earn plenty of it.
+- **Bots do no quests and do not trade with players.** Deliberate scope. The
+ quests which matter for progression (the class changes) are performed
+ directly, and trading would be an abuse surface.
+- **Adding a game server to a running deployment does not spread the bots onto
+ it before a restart.** Deliberate: moving a bot between two running servers
+ would animate one account from two persistence contexts, which corrupts the
+ character.
+
+## Enabling it on a server
+
+1. Enable the *Bots* plugin and set the number of accounts. Each account
+ animates up to five characters, so 50 accounts × 5 = 250 bots.
+2. Check that the population fits. The bots of a game server may occupy
+ `Bot capacity %` of its player limit — with the default of 60 %, a server for
+ 1000 players hosts up to 600 bots. What does not fit stays offline, and the
+ plugin says so in the log: raise the player limit, raise the share, or add a
+ game server, over which the population then spreads by itself.
+3. Restart the server. The population is generated on the first start and
+ reloaded afterwards.
+4. To build a fresh population, set `Reset bots`: it purges the old one,
+ generates a new one, and clears the flag again.
diff --git a/docs/Readme.md b/docs/Readme.md
index 245b730ef..545675219 100644
--- a/docs/Readme.md
+++ b/docs/Readme.md
@@ -140,6 +140,9 @@ database (e.g. RavenDB).
* [Master Skill System](MasterSystem.md): Description about the master skill system
+* [Server-side AI Bots](Bots.md): Description about the bots which populate
+the server
+
* [GameMap](GameMap.md): Description about the GameMap implementation
* [Progress](Progress.md): Information about the feature implementation
diff --git a/src/DataModel/Entities/Account.cs b/src/DataModel/Entities/Account.cs
index db22a7174..9a543c4c2 100644
--- a/src/DataModel/Entities/Account.cs
+++ b/src/DataModel/Entities/Account.cs
@@ -130,6 +130,13 @@ public class Account
///
public bool IsTemplate { get; set; }
+ ///
+ /// Gets or sets a value indicating whether this account is a server-side bot account.
+ /// Bot accounts are generated and maintained by the bot feature; this flag is the reliable
+ /// marker used to load them on startup (instead of regenerating) and to purge them.
+ ///
+ public bool IsBot { get; set; }
+
///
/// Gets or sets the characters.
///
diff --git a/src/GameLogic/Bots/BotConfiguration.cs b/src/GameLogic/Bots/BotConfiguration.cs
new file mode 100644
index 000000000..66279a149
--- /dev/null
+++ b/src/GameLogic/Bots/BotConfiguration.cs
@@ -0,0 +1,122 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using System.ComponentModel.DataAnnotations;
+
+///
+/// The admin-panel editable configuration of the .
+///
+public class BotConfiguration
+{
+ ///
+ /// The hard limit of characters a single account can hold in the game.
+ ///
+ public const int MaxCharactersPerAccountLimit = 5;
+
+ ///
+ /// Gets or sets a value indicating whether the bot feature is enabled.
+ /// Disabled by default so that enabling bots is always an explicit, deliberate action.
+ ///
+ [Display(Name = "Enabled", Description = "If enabled, bots are spawned after the server has started.")]
+ public bool Enabled { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether all bot accounts and characters should be deleted.
+ /// When set, the feature purges every bot account on the next startup before generating fresh
+ /// ones, and then automatically clears this flag again. Use it to reset the bot population.
+ ///
+ [Display(Name = "Reset bots", Description = "Deletes all bot accounts and characters on the next start, then regenerates them. Clears itself afterwards.")]
+ public bool ResetBots { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the bot population rotates its presence over the day:
+ /// fewer bots are online at night, most in the evening, with bots smoothly logging in and out -
+ /// like a real player base, instead of the same characters being online 24/7.
+ ///
+ [Display(Name = "Presence rotation", Description = "Bots log in and out over the day (fewest at night, most in the evening) instead of all being online 24/7.")]
+ public bool PresenceRotation { get; set; } = true;
+
+ ///
+ /// Gets or sets the share (in percent) of bots which stays online at the quietest time of day.
+ /// 100 effectively disables the rotation effect.
+ ///
+ [Display(Name = "Min. online share %", Description = "Percentage of the bot population which stays online at the quietest hour (100 = no rotation effect).")]
+ public int MinOnlineSharePercent { get; set; } = 60;
+
+ ///
+ /// Gets or sets the number of bot accounts. Together with
+ /// this defines the generated bot population, e.g. 10 accounts × 5 characters = 50 bot characters.
+ ///
+ [Display(Name = "Number of accounts", Description = "How many bot accounts to maintain.")]
+ [Range(0, 1000)]
+ public int NumberOfAccounts { get; set; } = 10;
+
+ ///
+ /// Gets or sets the number of characters per bot account. An account can hold at most
+ /// (5) characters, so this value is clamped on use.
+ ///
+ [Display(Name = "Characters per account", Description = "How many characters each bot account holds (max 5).")]
+ [Range(1, MaxCharactersPerAccountLimit)]
+ public int MaxCharactersPerAccount { get; set; } = MaxCharactersPerAccountLimit;
+
+ ///
+ /// Gets or sets the share (in percent) of a game server's maximum player count which its bots may
+ /// occupy. Bots count towards that limit like players do, and a full server turns new clients away -
+ /// so the rest of the capacity stays reserved for real players, who must never be denied a slot by a
+ /// bot. The population is split over all configured game servers accordingly (see
+ /// ); accounts which do not fit stay offline until the servers offer
+ /// the room for them.
+ ///
+ [Display(Name = "Bot capacity %", Description = "Share of a game server's maximum player count which its bots may occupy; the rest stays reserved for real players.")]
+ [Range(1, 100)]
+ public int BotCapacityPercent { get; set; } = 60;
+
+ ///
+ /// Gets or sets a value indicating whether bots pay the configured reset costs (zen, reset items)
+ /// when they reset their character on a server with the reset feature enabled. Off by default:
+ /// bots don't take part in the player economy the costs are balanced for, so charging them only
+ /// stalls their progression (a bot can't farm zen for a billion-zen reset the way players trade).
+ ///
+ [Display(Name = "Bots pay reset costs", Description = "If enabled, bots consume the configured zen/item costs for their resets like human players (default: free bot resets).")]
+ public bool BotsPayResetCosts { get; set; }
+
+ ///
+ /// Gets or sets a comma separated list of login names of existing accounts to animate as bots.
+ /// This is an optional extra hook alongside the generated population (see
+ /// ): every listed account gets a bot driving its first character.
+ /// These accounts are animated as-is and are not part of the partitioned, capacity-limited
+ /// population, so leave it empty unless you specifically want to drive existing accounts.
+ ///
+ [Display(Name = "Extra accounts to animate", Description = "Comma separated login names of existing accounts to animate as bots, in addition to the generated population.")]
+ public string ProofOfConceptAccounts { get; set; } = string.Empty;
+
+ ///
+ /// Gets the effective, clamped number of characters per account.
+ ///
+ /// A value between 1 and .
+ /// Deliberately a method: a get-only property would end up in the serialized plugin configuration JSON.
+ public int GetEffectiveCharactersPerAccount()
+ => Math.Clamp(this.MaxCharactersPerAccount, 1, MaxCharactersPerAccountLimit);
+
+ ///
+ /// Gets the effective, clamped share of a server's player capacity which its bots may occupy.
+ ///
+ /// A value between 1 and 100.
+ /// Deliberately a method, like .
+ public int GetEffectiveBotCapacityPercent()
+ => Math.Clamp(this.BotCapacityPercent, 1, 100);
+
+ ///
+ /// Parses into the distinct, trimmed login names.
+ ///
+ /// The list of login names.
+ /// Deliberately a method: a get-only property would end up in the serialized plugin configuration JSON.
+ public IReadOnlyList ParseProofOfConceptAccounts()
+ => this.ProofOfConceptAccounts
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+}
diff --git a/src/GameLogic/Bots/BotEquipmentHandler.cs b/src/GameLogic/Bots/BotEquipmentHandler.cs
new file mode 100644
index 000000000..9f6bc91e9
--- /dev/null
+++ b/src/GameLogic/Bots/BotEquipmentHandler.cs
@@ -0,0 +1,283 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.GameLogic.Offline;
+using MUnique.OpenMU.GameLogic.PlayerActions.Items;
+
+///
+/// Lets a bot progress its equipment like a real player: dropped gear is evaluated before pickup
+/// (see , used by the offline ), and looted
+/// upgrades are periodically equipped; the replaced piece goes into the backpack and is sold on the next
+/// shopping trip, so the backpack does not silt up with junk. All moves go through the regular
+/// , which enforces the same class- and stat-requirements as for a human
+/// player - including which hand a weapon needs (see ).
+///
+internal static class BotEquipmentHandler
+{
+ /// A candidate must beat the equipped piece by at least this score margin to be worth swapping.
+ private const int UpgradeScoreMargin = 1;
+
+ /// The highest item group which is a weapon (0 sword, 1 axe, 2 mace, 3 spear, 4 bow, 5 staff).
+ private const byte LastWeaponGroup = 5;
+
+ /// The item group of the shields.
+ private const byte ShieldGroup = 6;
+
+ private static readonly MoveItemAction MoveAction = new();
+
+ ///
+ /// Determines whether the dropped item would be an upgrade over the bot's currently equipped gear,
+ /// so the pickup handler only collects items worth carrying.
+ ///
+ /// The bot player which would wear the item.
+ /// The dropped item to evaluate.
+ public static bool IsUpgradeFor(Player player, Item item)
+ {
+ return TryPlanSwap(player, item) is not null;
+ }
+
+ ///
+ /// Scans the bot's backpack for equippable upgrades and puts the best one on; the replaced piece
+ /// stays in the backpack, where the next shopping trip sells it.
+ ///
+ /// The bot player whose backpack is scanned.
+ public static async ValueTask TryEquipUpgradesAsync(OfflinePlayer player)
+ {
+ if (player.Inventory is not { } inventory)
+ {
+ return;
+ }
+
+ // Snapshot, because equipping mutates the item collection while we iterate.
+ var backpackItems = inventory.Items
+ .Where(i => i.ItemSlot >= InventoryConstants.EquippableSlotsCount)
+ .ToList();
+
+ foreach (var item in backpackItems)
+ {
+ if (TryPlanSwap(player, item) is not { } plan)
+ {
+ continue;
+ }
+
+ if (await TryApplySwapAsync(player, inventory, item, plan).ConfigureAwait(false))
+ {
+ // One swap per pass keeps the work per tick small; the next pass picks up the rest.
+ return;
+ }
+ }
+ }
+
+ ///
+ /// Puts the planned piece on: the gear it replaces goes to the backpack first (an equip only works
+ /// into a free slot), then the candidate is equipped through the regular .
+ /// If the engine refuses the equip after all - the requirements are checked against the TOTAL stats,
+ /// which drop as soon as the old piece with its bonuses comes off - everything moved so far is put
+ /// back on. Without that rollback the bot ended up with an empty slot, re-equipped the old piece on
+ /// its next pass and started over: hundreds of swaps per hour, fighting without a weapon half of the
+ /// time.
+ ///
+ /// true if the candidate is now equipped.
+ private static async ValueTask TryApplySwapAsync(OfflinePlayer player, IStorage inventory, Item item, EquipSwap plan)
+ {
+ var undo = new List<(Item Item, byte EquipSlot)>(2);
+ foreach (var removed in plan.Removed)
+ {
+ if (inventory.CheckInvSpace(removed) is not { } freeSlot)
+ {
+ // No room of the item's SIZE in the backpack (a 2x3 armor needs a 2x3 hole).
+ await RollbackAsync(player, undo).ConfigureAwait(false);
+ return false;
+ }
+
+ var equipSlot = removed.ItemSlot;
+ await MoveAction.MoveItemAsync(player, equipSlot, Storages.Inventory, freeSlot, Storages.Inventory).ConfigureAwait(false);
+ if (inventory.GetItem(equipSlot) is not null)
+ {
+ player.Logger.LogDebug("Bot '{Name}' could not take off '{Item}' for a swap.", player.Name, removed);
+ await RollbackAsync(player, undo).ConfigureAwait(false);
+ return false;
+ }
+
+ undo.Add((removed, equipSlot));
+ }
+
+ await MoveAction.MoveItemAsync(player, item.ItemSlot, Storages.Inventory, plan.Slot, Storages.Inventory).ConfigureAwait(false);
+ if (inventory.GetItem(plan.Slot) != item)
+ {
+ player.Logger.LogDebug("Bot '{Name}' could not equip '{Item}' - putting its old gear back on.", player.Name, item);
+ await RollbackAsync(player, undo).ConfigureAwait(false);
+ return false;
+ }
+
+ player.Logger.LogInformation(
+ "Bot '{Name}' equipped '{New}'{Replaced}.",
+ player.Name,
+ item,
+ plan.Removed.Count == 0 ? string.Empty : $" (replacing {string.Join(", ", plan.Removed.Select(r => $"'{r}'"))})");
+
+ // The outgrown gear stays in the backpack and is SOLD on the next shopping trip (see
+ // BotShoppingHandler.IsSellableJunk): dropping it on the ground littered the hunting grounds
+ // with the bots' hand-me-downs, which the bots then picked up again - including the bot which
+ // had just dropped the piece, whose persistence context still tracked it (an entity conflict on
+ // the pickup). Selling it also feeds the Zen the bot restocks its potions with.
+ return true;
+ }
+
+ private static async ValueTask RollbackAsync(OfflinePlayer player, List<(Item Item, byte EquipSlot)> undo)
+ {
+ foreach (var (item, equipSlot) in undo)
+ {
+ await MoveAction.MoveItemAsync(player, item.ItemSlot, Storages.Inventory, equipSlot, Storages.Inventory).ConfigureAwait(false);
+ if (player.Inventory?.GetItem(equipSlot) != item)
+ {
+ player.Logger.LogWarning("Bot '{Name}' could not put '{Item}' back on into slot {Slot}.", player.Name, item, equipSlot);
+ }
+ }
+ }
+
+ ///
+ /// Plans how the item could be worn: into which slot, and which equipped pieces it would replace.
+ /// Returns null when the bot would not (or could not) wear it - which is exactly what makes an
+ /// item worth picking up from the ground, so the pickup handler asks the same question through
+ /// . Beside the piece in the target slot, a two-handed weapon also replaces
+ /// whatever blocks the other hand: the engine refuses to equip it otherwise (see
+ /// ), and a bot which does not plan for that
+ /// keeps trying (and failing) to put it on forever.
+ ///
+ private static EquipSwap? TryPlanSwap(Player player, Item item)
+ {
+ if (item.Definition is not { } definition
+ || player.SelectedCharacter?.CharacterClass is not { } characterClass
+ || player.Inventory is not { } inventory
+ || !IsWearableCandidate(player, definition, characterClass)
+ || !player.CompliesRequirements(item))
+ {
+ return null;
+ }
+
+ var candidateScore = Score(item);
+ EquipSwap? best = null;
+ var bestReplacedScore = 0;
+ foreach (var slot in GetTargetSlots(definition))
+ {
+ var equipped = inventory.GetItem(slot);
+ if (equipped?.Definition?.IsAmmunition == true)
+ {
+ // Never displace the ammunition (an archer's arrows) - the bow would stop working.
+ continue;
+ }
+
+ var removed = new List- (2);
+ if (equipped is not null)
+ {
+ removed.Add(equipped);
+ }
+
+ if (definition.ConflictsWithEquippedHands(inventory, slot))
+ {
+ // Only the main hand resolves such a conflict: taking the two-handed weapon off to fit a
+ // shield into the other hand would disarm the bot.
+ if (slot != InventoryConstants.LeftHandSlot
+ || inventory.GetItem(InventoryConstants.RightHandSlot) is not { } blocking)
+ {
+ continue;
+ }
+
+ removed.Add(blocking);
+ }
+
+ var replacedScore = removed.Sum(Score);
+ if (removed.Count > 0 && replacedScore + UpgradeScoreMargin > candidateScore)
+ {
+ continue;
+ }
+
+ // Prefer the cheapest swap: an empty slot beats replacing gear, and among occupied slots the
+ // weakest gear goes first (this is what spreads rings over both ring slots).
+ if (best is null || replacedScore < bestReplacedScore)
+ {
+ best = new EquipSwap(slot, removed);
+ bestReplacedScore = replacedScore;
+ }
+ }
+
+ return best;
+ }
+
+ ///
+ /// The slots the bot considers for this piece: a weapon only goes into the main hand and a shield
+ /// only into the off-hand - a bot filling its off-hand with a second (junk) weapon it happens to be
+ /// qualified for is neither useful nor a sight any real character offers. Everything else may go into
+ /// any of its qualified slots (rings have two).
+ ///
+ private static IEnumerable GetTargetSlots(ItemDefinition definition)
+ {
+ var slots = definition.ItemSlot!.ItemSlots.Select(s => (byte)s).ToList();
+ if (definition.Group <= LastWeaponGroup && slots.Contains(InventoryConstants.LeftHandSlot))
+ {
+ return [InventoryConstants.LeftHandSlot];
+ }
+
+ if (definition.Group == ShieldGroup && slots.Contains(InventoryConstants.RightHandSlot))
+ {
+ return [InventoryConstants.RightHandSlot];
+ }
+
+ return slots;
+ }
+
+ ///
+ /// Whether the item is gear this bot would wear at all: an equippable, class-qualified piece which -
+ /// if it is a weapon - matches the fighting style of the bot's build (an elf only considers bows, a
+ /// caster only staves), so bots don't fill their hands with random qualified junk like a Small Axe.
+ ///
+ private static bool IsWearableCandidate(Player player, ItemDefinition definition, CharacterClass characterClass)
+ {
+ if (definition.ItemSlot is not { ItemSlots.Count: > 0 }
+ || definition.IsAmmunition
+ || !definition.QualifiedCharacters.Contains(characterClass))
+ {
+ return false;
+ }
+
+ if (definition.Group > LastWeaponGroup)
+ {
+ return true;
+ }
+
+ var resetMeta = BotResetHandler.GetResetConfiguration(player.GameContext) is not null;
+ return BotProgression.IsPreferredWeaponGroup(characterClass, player.SelectedCharacter!.Name, resetMeta, (byte)definition.Group);
+ }
+
+ ///
+ /// A rough, monotonic quality score of an equippable item: the definition's drop level tracks the
+ /// gear tier, the item level its upgrades, and excellent/ancient options add their extra worth.
+ ///
+ private static int Score(Item item)
+ {
+ if (item.Definition is not { } definition)
+ {
+ return 0;
+ }
+
+ var score = definition.DropLevel + (item.Level * 3);
+ score += 12 * item.ItemOptions.Count(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent);
+ if (item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0))
+ {
+ score += 15;
+ }
+
+ return score;
+ }
+
+ ///
+ /// A planned equip: the slot the candidate goes into, and the equipped pieces which have to come off
+ /// for it (the gear in the target slot, plus the other hand's item when a two-handed weapon needs it).
+ ///
+ private sealed record EquipSwap(byte Slot, IReadOnlyList
- Removed);
+}
diff --git a/src/GameLogic/Bots/BotFeaturePlugIn.cs b/src/GameLogic/Bots/BotFeaturePlugIn.cs
new file mode 100644
index 000000000..2ea6afa82
--- /dev/null
+++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs
@@ -0,0 +1,576 @@
+//
+// 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.Linq;
+using System.Runtime.InteropServices;
+using System.Threading;
+using Microsoft.Extensions.Logging;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic.PlugIns;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Feature plugin which spawns and maintains server-side bots.
+/// Appears in the "Feature Plugins" section of the admin panel next to the MU Helper and reset features.
+///
+[PlugIn]
+[Display(Name = "Bots", Description = "Spawns server-side bots which hunt monsters on the maps. Configure the accounts to animate and enable the feature.")]
+[Guid("6F3A2B91-7C4E-4D88-9A1F-2E5C0B7A4D63")]
+public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration
+{
+ ///
+ /// Delay before the first spawn attempt, giving the server time to finish starting up
+ /// (maps, configuration and plugins fully initialized).
+ ///
+ private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15);
+
+ private static readonly TimeSpan MaintenanceInterval = TimeSpan.FromSeconds(60);
+ private static readonly TimeSpan PartyReformInterval = TimeSpan.FromMinutes(60);
+
+ ///
+ /// The typical activity of a player base by local hour (0..1): quietest in the early morning,
+ /// busiest in the evening. Scales between
+ /// and 100% of the bot population.
+ ///
+ private static readonly double[] ActivityByHour =
+ [
+ 0.30, 0.15, 0.05, 0.00, 0.00, 0.05, 0.10, 0.20, 0.30, 0.35, 0.40, 0.45,
+ 0.50, 0.50, 0.55, 0.60, 0.70, 0.80, 0.90, 1.00, 1.00, 0.95, 0.80, 0.50,
+ ];
+
+ ///
+ /// The state of the feature, per game server: the plugin instance is shared by all game servers of
+ /// the process, while is called by each of them separately. One shared
+ /// state would mean the server whose timer fires first animates the whole population (which is how
+ /// the bots used to end up on a single server), and the other servers doing nothing at all.
+ ///
+ private readonly ConcurrentDictionary _states = new();
+
+ ///
+ /// The phase of a game server's single startup pass. The periodic task timer fires every second
+ /// WITHOUT awaiting the previous invocation, so during the minutes-long generation/spawn further
+ /// ticks arrive concurrently - they must neither re-enter the startup nor run the maintenance
+ /// (e.g. the presence rotation) against a half-spawned population.
+ ///
+ private enum StartupPhase
+ {
+ /// The startup has not run yet.
+ NotStarted = 0,
+
+ /// The startup (generation and spawn) is in progress.
+ InProgress = 1,
+
+ /// The startup is done; the server is in maintenance mode.
+ Done = 2,
+ }
+
+ ///
+ public BotConfiguration? Configuration { get; set; }
+
+ ///
+ public async ValueTask ExecuteTaskAsync(GameContext gameContext)
+ {
+ var state = this._states.GetOrAdd(gameContext, _ => new ServerState());
+ if (state.StartupState == (int)StartupPhase.Done)
+ {
+ await this.RunMaintenanceAsync(gameContext, state).ConfigureAwait(false);
+ return;
+ }
+
+ if (DateTime.UtcNow < state.NextRunUtc
+ || Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.NotStarted) != (int)StartupPhase.NotStarted)
+ {
+ return;
+ }
+
+ var configuration = this.Configuration ??= CreateDefaultConfiguration();
+ if (!configuration.Enabled)
+ {
+ // Not spawned - re-check on the following ticks, the feature may get enabled later.
+ Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.NotStarted);
+ return;
+ }
+
+ try
+ {
+ await this.SpawnPopulationAsync(gameContext, state, configuration).ConfigureAwait(false);
+ }
+ finally
+ {
+ // Like before: the startup runs once, even when parts of it failed (the errors are
+ // logged); the maintenance pass takes over from here.
+ Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.Done);
+ }
+ }
+
+ private async ValueTask SpawnPopulationAsync(GameContext gameContext, ServerState state, BotConfiguration configuration)
+ {
+ var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
+ using var scope = logger.BeginScope(gameContext);
+
+ var generator = new BotGenerator(gameContext, logger);
+ var partition = state.Partition = await BotServerPartition.CreateAsync(gameContext, configuration, logger).ConfigureAwait(false);
+
+ if (configuration.ResetBots && partition.IsGenerator)
+ {
+ try
+ {
+ var deleted = await generator.DeleteAllBotsAsync().ConfigureAwait(false);
+ logger.LogInformation("Reset requested: purged {Deleted} bot account(s).", deleted);
+
+ // Clear the flag (in memory and persisted) so the next restart does not purge again.
+ configuration.ResetBots = false;
+ await this.PersistConfigurationAsync(gameContext, configuration, logger).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to reset the bot population.");
+ }
+ }
+
+ if (partition.IsGenerator)
+ {
+ try
+ {
+ // Generate the persistent bot population if it is not there yet (idempotent). Only this
+ // server does it - see BotServerPartition.IsGenerator; the others find the accounts once
+ // they exist and retry the spawns of their own share meanwhile.
+ var created = await generator.EnsureBotsAsync(configuration.NumberOfAccounts, configuration.MaxCharactersPerAccount).ConfigureAwait(false);
+ if (created > 0)
+ {
+ logger.LogInformation("Generated {Created} new bot account(s).", created);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to generate the bot population.");
+ }
+ }
+
+ var charactersPerAccount = Math.Clamp(
+ Math.Min(configuration.MaxCharactersPerAccount, gameContext.Configuration.MaximumCharactersPerAccount),
+ 1,
+ BotConfiguration.MaxCharactersPerAccountLimit);
+
+ var started = 0;
+ var total = 0;
+ for (var i = partition.FirstAccount; i < partition.FirstAccount + partition.AccountCount; i++)
+ {
+ var loginName = BotGenerator.GetLoginName(i);
+ for (byte slot = 0; slot < charactersPerAccount; slot++)
+ {
+ total++;
+ try
+ {
+ if (await state.Manager.SpawnBotAsync(gameContext, loginName, slot).ConfigureAwait(false))
+ {
+ started++;
+ }
+ else
+ {
+ // The account may just not be generated yet (another server is generating the
+ // population right now) - the maintenance pass retries it.
+ state.PendingRespawns.Enqueue((loginName, slot));
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to spawn bot for account '{LoginName}' (slot {Slot}).", loginName, slot);
+ }
+ }
+ }
+
+ // The proof-of-concept accounts remain an optional extra hook to animate existing (non-bot)
+ // accounts. They are not part of the partitioned population, so only one server animates them.
+ if (partition.IsGenerator)
+ {
+ foreach (var loginName in configuration.ParseProofOfConceptAccounts())
+ {
+ try
+ {
+ if (await state.Manager.SpawnBotAsync(gameContext, loginName).ConfigureAwait(false))
+ {
+ started++;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to spawn proof-of-concept bot for account '{LoginName}'.", loginName);
+ }
+ }
+ }
+
+ logger.LogInformation("Bot feature started {Started} of {Total} bots.", started, total);
+
+ try
+ {
+ await state.Manager.FormPartiesAsync(gameContext).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to form bot parties.");
+ }
+ }
+
+ ///
+ public void ForceStart()
+ {
+ foreach (var state in this._states.Values)
+ {
+ state.NextRunUtc = DateTime.UtcNow;
+ }
+ }
+
+ ///
+ public object CreateDefaultConfig()
+ {
+ return CreateDefaultConfiguration();
+ }
+
+ private static BotConfiguration CreateDefaultConfiguration()
+ {
+ return new BotConfiguration();
+ }
+
+ ///
+ /// Runs the periodic post-spawn maintenance: the presence rotation (one bot in or out per pass, so
+ /// the population ebbs and flows smoothly over the day) and an hourly party re-formation which
+ /// groups bots that lost or never had a party (e.g. after rotating back in).
+ ///
+ private async ValueTask RunMaintenanceAsync(GameContext gameContext, ServerState state)
+ {
+ if (DateTime.UtcNow < state.NextMaintenanceUtc)
+ {
+ return;
+ }
+
+ // The engine fires the periodic tasks every second WITHOUT awaiting the previous run, so a pass
+ // which takes longer than its interval (spawning a bot loads a whole account from the database)
+ // would otherwise overlap with itself: two passes restarting the same bot, rotating the presence
+ // twice, forming parties in parallel. One pass at a time - like the startup below.
+ if (Interlocked.CompareExchange(ref state.MaintenanceRunning, 1, 0) != 0)
+ {
+ return;
+ }
+
+ var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
+ try
+ {
+ state.NextMaintenanceUtc = DateTime.UtcNow + MaintenanceInterval;
+
+ var configuration = this.Configuration;
+ if (configuration?.Enabled != true)
+ {
+ return;
+ }
+
+ await this.RespawnPendingAsync(gameContext, state).ConfigureAwait(false);
+ await this.RestartFaultedBotsAsync(gameContext, state, logger).ConfigureAwait(false);
+ await this.EvolveDueMastersAsync(gameContext, state, logger).ConfigureAwait(false);
+
+ if (configuration.PresenceRotation)
+ {
+ await this.RotatePresenceAsync(gameContext, state, configuration, logger).ConfigureAwait(false);
+ }
+
+ if (DateTime.UtcNow >= state.NextPartyReformUtc)
+ {
+ state.NextPartyReformUtc = DateTime.UtcNow + PartyReformInterval;
+ await state.Manager.FormPartiesAsync(gameContext).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Bot maintenance failed.");
+ }
+ finally
+ {
+ Interlocked.Exchange(ref state.MaintenanceRunning, 0);
+ }
+ }
+
+ ///
+ /// Restarts bots whose AI keeps throwing (see ). The
+ /// engine's attribute system is not thread-safe, and a lost race can corrupt a character's attribute
+ /// graph for good: the bot stops playing and every following tick throws the same exception, up to a
+ /// flood of them per second. A fresh login rebuilds the graph and heals it - the same thing a player
+ /// would do, and the only cure available from outside the engine. Runs from the maintenance pass,
+ /// which is the only place allowed to restart a bot.
+ ///
+ private async ValueTask RestartFaultedBotsAsync(GameContext gameContext, ServerState state, ILogger logger)
+ {
+ foreach (var bot in state.Manager.Bots)
+ {
+ if (!bot.AwaitsFaultRestart)
+ {
+ continue;
+ }
+
+ var loginName = bot.Account?.LoginName;
+ var characterSlot = bot.SelectedCharacter?.CharacterSlot;
+ bot.AwaitsFaultRestart = false;
+ try
+ {
+ if (!await state.Manager.RestartBotAsync(gameContext, bot).ConfigureAwait(false)
+ && loginName is not null
+ && characterSlot is { } slot)
+ {
+ state.PendingRespawns.Enqueue((loginName, slot));
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to restart the faulted bot '{Name}'.", bot.Name);
+ }
+ }
+ }
+
+ ///
+ /// Evolves bots which reached the game's maximum level into their master class (see
+ /// for the rules, including the iron rule of reset servers).
+ /// Runs from the maintenance pass - outside the bot's own AI tick - because the evolved bot is
+ /// restarted right away (see ), which must not happen
+ /// from within one of its own timer callbacks.
+ ///
+ private async ValueTask EvolveDueMastersAsync(GameContext gameContext, ServerState state, ILogger logger)
+ {
+ foreach (var bot in state.Manager.Bots)
+ {
+ // Not while it hunts with a human (the restart would desert the group), sits in an NPC
+ // dialog or lies dead - like a due reset, the evolution simply happens on a later pass.
+ if (BotPartyHandler.HasHumanCompanion(bot)
+ || bot.PlayerState.CurrentState != PlayerState.EnteredWorld
+ || !bot.IsAlive)
+ {
+ continue;
+ }
+
+ if (bot.AwaitsMasterRestart)
+ {
+ await this.RestartEvolvedBotAsync(gameContext, state, bot, logger).ConfigureAwait(false);
+ continue;
+ }
+
+ if (BotMasterHandler.IsMasterEvolutionDue(bot))
+ {
+ // The class change and its save go through the bot's own tick, like every other
+ // bot-initiated mutation (see OfflinePlayer.PendingBotActions): running them from the
+ // maintenance pass would write the character through the same persistence context the
+ // combat tick is using at that very moment. The restart follows on the next pass -
+ // it must NOT happen from within one of the bot's own timer callbacks.
+ bot.PendingBotActions.Enqueue(async () =>
+ {
+ if (await BotMasterHandler.TryEvolveAsync(bot).ConfigureAwait(false))
+ {
+ bot.AwaitsMasterRestart = true;
+ }
+ });
+ }
+ }
+ }
+
+ ///
+ /// Gives the freshly evolved bot the "relog" it needs: the master class's base attributes (master
+ /// experience rate, master points per level) and the master level stat are only mounted when a
+ /// character enters the world (see ).
+ ///
+ private async ValueTask RestartEvolvedBotAsync(GameContext gameContext, ServerState state, BotPlayer bot, ILogger logger)
+ {
+ // Captured before the restart - the disposed bot loses its account and character.
+ var loginName = bot.Account?.LoginName;
+ var characterSlot = bot.SelectedCharacter?.CharacterSlot;
+ bot.AwaitsMasterRestart = false;
+ try
+ {
+ if (!await state.Manager.RestartBotAsync(gameContext, bot).ConfigureAwait(false)
+ && loginName is not null
+ && characterSlot is { } slot)
+ {
+ // The evolution is persisted; only the presence is at risk. RespawnPendingAsync
+ // drops the entry when the bot is (still or again) online, so a kept-alive old
+ // instance or a rotation comeback doesn't get doubled.
+ logger.LogWarning("Evolved bot '{Name}' could not be respawned right away; retrying on the next pass.", bot.Name);
+ state.PendingRespawns.Enqueue((loginName, slot));
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to restart bot '{Name}' after its master evolution.", bot.Name);
+ }
+ }
+
+ ///
+ /// Retries bringing back bots whose respawn after the master evolution failed.
+ ///
+ private async ValueTask RespawnPendingAsync(GameContext gameContext, ServerState state)
+ {
+ var count = state.PendingRespawns.Count;
+ for (var i = 0; i < count && state.PendingRespawns.TryDequeue(out var entry); i++)
+ {
+ if (state.Manager.IsActive(entry.Login, entry.Slot))
+ {
+ continue;
+ }
+
+ if (!await state.Manager.SpawnBotAsync(gameContext, entry.Login, entry.Slot).ConfigureAwait(false))
+ {
+ state.PendingRespawns.Enqueue(entry);
+ }
+ }
+ }
+
+ ///
+ /// Rotates the presence of the bots THIS server animates (see ):
+ /// each server keeps the daily curve within its own share of the population.
+ ///
+ private async ValueTask RotatePresenceAsync(GameContext gameContext, ServerState state, BotConfiguration configuration, ILogger logger)
+ {
+ if (state.Partition is not { AccountCount: > 0 } partition)
+ {
+ return;
+ }
+
+ var charactersPerAccount = configuration.GetEffectiveCharactersPerAccount();
+ var totalPopulation = partition.AccountCount * charactersPerAccount;
+ if (totalPopulation <= 0)
+ {
+ return;
+ }
+
+ var minShare = Math.Clamp(configuration.MinOnlineSharePercent, 0, 100) / 100.0;
+
+ // Local wall-clock time on purpose (the rest of the class uses UtcNow for durations): this curve
+ // models human presence - fewest in the early morning, most in the evening - so it has to follow
+ // the players' day, which is the host's local time, not UTC. On a UTC-configured host the two
+ // coincide; on a host set to the player base's zone, local time keeps the peak in their evening.
+ var activity = ActivityByHour[DateTime.Now.Hour];
+ var targetOnline = (int)Math.Round(totalPopulation * (minShare + ((1.0 - minShare) * activity)));
+ var online = state.Manager.Bots.Count;
+
+ if (online < targetOnline)
+ {
+ // Bring one bot online: pick a random character which is currently offline.
+ var offline = new List<(string Login, byte Slot)>();
+ for (var i = partition.FirstAccount; i < partition.FirstAccount + partition.AccountCount; i++)
+ {
+ var loginName = BotGenerator.GetLoginName(i);
+ for (byte slot = 0; slot < charactersPerAccount; slot++)
+ {
+ if (!state.Manager.IsActive(loginName, slot))
+ {
+ offline.Add((loginName, slot));
+ }
+ }
+ }
+
+ if (offline.SelectRandom() is { Login: not null } candidate
+ && await state.Manager.SpawnBotAsync(gameContext, candidate.Login, candidate.Slot).ConfigureAwait(false))
+ {
+ logger.LogInformation("Bot presence rotation: +1 (online {Online}/{Target} of {Total}).", online + 1, targetOnline, totalPopulation);
+ }
+ }
+ else if (online > targetOnline)
+ {
+ var stopped = await state.Manager.StopRandomBotAsync().ConfigureAwait(false);
+ if (stopped is not null)
+ {
+ logger.LogInformation("Bot presence rotation: -1 '{Name}' (online {Online}/{Target} of {Total}).", stopped, online - 1, targetOnline, totalPopulation);
+ }
+ }
+ }
+
+ ///
+ /// Persists the current configuration back to its row, so a
+ /// programmatic change (e.g. clearing the reset flag) survives a restart.
+ ///
+ private async ValueTask PersistConfigurationAsync(GameContext gameContext, BotConfiguration configuration, ILogger logger)
+ {
+ try
+ {
+ // Load the configuration through a fresh context so the PlugInConfiguration entity is tracked
+ // and the change is actually persisted - the in-memory cached config graph is not tracked.
+ using var context = gameContext.PersistenceContextProvider.CreateNewContext();
+ var typeId = typeof(BotFeaturePlugIn).GUID;
+ var gameConfiguration = (await context.GetAsync().ConfigureAwait(false)).FirstOrDefault();
+ var entity = gameConfiguration?.PlugInConfigurations.FirstOrDefault(c => c.TypeId == typeId);
+ if (entity is null)
+ {
+ logger.LogWarning("Could not find the bot plugin configuration row to persist.");
+ return;
+ }
+
+ entity.SetConfiguration(configuration, gameContext.PlugInManager.CustomConfigReferenceHandler);
+ await context.SaveChangesAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to persist the bot plugin configuration.");
+ }
+ }
+
+ ///
+ /// The bot feature's state of ONE game server: its own share of the population, its own bots, and
+ /// its own startup and maintenance schedule (see ).
+ ///
+ private sealed class ServerState
+ {
+ ///
+ /// The of this server, as a raw : it is transitioned
+ /// with , which has no overload for arbitrary enum types, so the field
+ /// stays an and the phase constants are cast at the call sites.
+ ///
+ private int _startupState;
+
+ ///
+ /// 1 while a maintenance pass runs, so the next timer tick doesn't start a second one in
+ /// parallel. Interlocked-updated, hence a field.
+ ///
+ private int _maintenanceRunning;
+
+ ///
+ /// Gets the bots this server animates.
+ ///
+ public BotManager Manager { get; } = new();
+
+ ///
+ /// Gets the bots whose spawn failed - the account may not be generated yet (another game server
+ /// is generating the population), or a respawn after the master evolution did not go through.
+ /// Retried on the following maintenance passes.
+ ///
+ public ConcurrentQueue<(string Login, byte Slot)> PendingRespawns { get; } = new();
+
+ ///
+ /// Gets or sets the share of the bot population which this server animates.
+ ///
+ public BotServerPartition? Partition { get; set; }
+
+ ///
+ /// Gets or sets the time of this server's (single) startup pass.
+ ///
+ public DateTime NextRunUtc { get; set; } = DateTime.UtcNow + StartupDelay;
+
+ ///
+ /// Gets or sets the time of this server's next maintenance pass.
+ ///
+ public DateTime NextMaintenanceUtc { get; set; } = DateTime.UtcNow + StartupDelay + StartupDelay;
+
+ ///
+ /// Gets or sets the time of this server's next bot party re-formation.
+ ///
+ public DateTime NextPartyReformUtc { get; set; } = DateTime.UtcNow + PartyReformInterval;
+
+ ///
+ /// Gets a reference to the startup state, for the interlocked transitions of the startup pass.
+ ///
+ public ref int StartupState => ref this._startupState;
+
+ ///
+ /// Gets a reference to the maintenance flag, for the interlocked guard of the maintenance pass.
+ ///
+ public ref int MaintenanceRunning => ref this._maintenanceRunning;
+ }
+}
diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs
new file mode 100644
index 000000000..13e54038b
--- /dev/null
+++ b/src/GameLogic/Bots/BotGenerator.cs
@@ -0,0 +1,606 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using System.Linq;
+using System.Threading;
+using Microsoft.Extensions.Logging;
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.Resets;
+using MUnique.OpenMU.Persistence;
+
+///
+/// Generates and maintains the persistent population of bot accounts and their characters.
+///
+///
+/// Accounts are flagged with so they can be reliably reloaded on
+/// startup (instead of being regenerated) and purged on request. The account login names follow
+/// a deterministic, internal scheme () which is never shown to other
+/// players; the player-visible character names are realistic and unique (see ).
+/// Generation is idempotent: only the missing accounts are created, so it is safe to run on every start.
+///
+internal sealed class BotGenerator
+{
+ private const string LoginPrefix = "bot";
+
+ ///
+ /// BCrypt work factor for a bot account's password. The password is a random
+ /// which is discarded immediately and never used to log in - a bot is a connection-less
+ /// OfflinePlayer, so no client ever authenticates against it. A minimal factor is therefore
+ /// safe (a 128-bit random secret is infeasible to brute-force regardless of the factor) and keeps
+ /// generating a large population from becoming a multi-minute BCrypt bottleneck, while still storing
+ /// a valid BCrypt hash. The default factor is kept for real accounts.
+ ///
+ private const int BotPasswordWorkFactor = 4;
+
+ private const int MinLevel = 10;
+
+ ///
+ /// The highest generated level. High enough that the upper maps (Tarkan, Aida, Kanturu, ...) get a
+ /// resident bot population and that some bots start beyond the class evolution level
+ /// () - those are created as their second-generation
+ /// class right away, like a player who did the class quest long ago.
+ ///
+ private const int MaxLevel = 250;
+
+ ///
+ /// Skew of the level distribution: values above 1 make low and mid levels more common than high
+ /// ones, like a real server's population pyramid (an even spread would feel top-heavy).
+ ///
+ private const double LevelSkew = 1.6;
+ private const int StartMoney = 100000;
+
+ /// Upgrade level (+6) of the starter gear, giving fresh bots a survival buffer until they can warp.
+ private const byte StarterItemLevel = 6;
+
+ /// Number of inventory extensions (each 4 rows of 8 slots) a bot gets, so loot does not clog its backpack.
+ private const int BotInventoryExtensions = 4;
+
+ /// Highest item group that is a melee weapon (0 sword, 1 axe, 2 mace, 3 spear).
+ private const byte MaxMeleeGroup = 3;
+
+ /// Item group of bows (need ammunition).
+ private const byte BowGroup = 4;
+
+ /// Item group of staves/sticks (casters).
+ private const byte StaffGroup = 5;
+
+ /// Item group of body armor; its item number identifies the armor set.
+ private const byte ArmorGroup = 8;
+
+ ///
+ /// Armor set numbers tried in thematic order; the first the class is qualified for (by its chest piece)
+ /// is used: 5 Leather (warriors), 2 Pad (wizards), 10 Vine (elves), 39 Mistery (summoners), then fallbacks.
+ ///
+ private static readonly byte[] ArmorSetCandidates = { 5, 2, 10, 39, 6, 0, 4, 8 };
+
+ private readonly IGameContext _gameContext;
+ private readonly ILogger _logger;
+ private readonly BotNameGenerator _nameGenerator = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The game context.
+ /// The logger.
+ public BotGenerator(IGameContext gameContext, ILogger logger)
+ {
+ this._gameContext = gameContext;
+ this._logger = logger;
+ }
+
+ ///
+ /// Gets the deterministic, internal login name of the bot account with the given one-based index.
+ ///
+ /// The one-based account index.
+ /// The login name, e.g. bot0001 (kept within the 10 character account name limit).
+ public static string GetLoginName(int index) => $"{LoginPrefix}{index:D4}";
+
+ ///
+ /// Ensures that the configured number of bot accounts (each with the configured number of
+ /// characters) exists. Only missing accounts are created.
+ ///
+ /// The desired number of bot accounts.
+ /// The desired number of characters per account.
+ /// The cancellation token.
+ /// The number of accounts that were newly created.
+ public async ValueTask EnsureBotsAsync(int numberOfAccounts, int charactersPerAccount, CancellationToken cancellationToken = default)
+ {
+ var creatableClasses = this._gameContext.Configuration.CharacterClasses
+ .Where(c => c is { CanGetCreated: true, HomeMap: not null })
+ .ToList();
+ if (creatableClasses.Count == 0)
+ {
+ this._logger.LogWarning("No creatable character classes found - cannot generate bots.");
+ return 0;
+ }
+
+ var perAccount = Math.Clamp(
+ Math.Min(charactersPerAccount, this._gameContext.Configuration.MaximumCharactersPerAccount),
+ 1,
+ BotConfiguration.MaxCharactersPerAccountLimit);
+
+ var experienceTable = this._gameContext.ExperienceTable;
+ var maxLevel = Math.Min(MaxLevel, experienceTable.Length - 1);
+ var minLevel = Math.Clamp(MinLevel, 1, maxLevel);
+
+ // On servers with the reset feature the existing population has resets, so freshly generated
+ // bots get a random reset history too - a visitor should meet believable veterans (even TOP,
+ // max-reset characters), not a population uniformly starting from zero. Only possible when the
+ // configuration bounds the resets; unlimited-reset servers keep unseeded bots.
+ var resetConfiguration = BotResetHandler.GetResetConfiguration(this._gameContext);
+ var maxSeededResets = resetConfiguration?.ResetLimit is > 0 ? resetConfiguration.ResetLimit.Value : 0;
+
+ using var context = this._gameContext.PersistenceContextProvider.CreateNewPlayerContext(this._gameContext.Configuration);
+ var reservedNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var created = 0;
+
+ // Build a balanced, shuffled queue of classes so the whole population is evenly split across
+ // all creatable classes. Independent random draws leave visible skew at this scale (e.g. 11
+ // Summoners vs 4 Elves for 50 bots); the quota queue guarantees ~even counts, drawn per character.
+ var classQueue = BuildBalancedClassQueue(creatableClasses, numberOfAccounts * perAccount);
+
+ for (var i = 1; i <= numberOfAccounts; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var loginName = GetLoginName(i);
+ var existing = await context.GetAccountByLoginNameAsync(loginName, cancellationToken).ConfigureAwait(false);
+ if (existing is not null)
+ {
+ continue;
+ }
+
+ var account = context.CreateNew();
+ account.LoginName = loginName;
+ account.PasswordHash = BCrypt.Net.BCrypt.HashPassword(Guid.NewGuid().ToString(), BotPasswordWorkFactor);
+ account.IsBot = true;
+ account.Vault = context.CreateNew();
+
+ for (byte slot = 0; slot < perAccount; slot++)
+ {
+ var characterClass = classQueue.Count > 0 ? classQueue.Dequeue() : creatableClasses.SelectRandom()!;
+ var level = minLevel + (int)((maxLevel - minLevel) * Math.Pow(Rand.NextInt(0, 1001) / 1000.0, LevelSkew));
+ var seededResets = maxSeededResets > 0 ? Rand.NextInt(0, maxSeededResets + 1) : 0;
+ var name = await this._nameGenerator.GenerateUniqueAsync(context, reservedNames, cancellationToken).ConfigureAwait(false);
+ this.CreateCharacter(context, account, name, characterClass, level, slot, experienceTable, seededResets, resetConfiguration);
+ }
+
+ // Save per account so a single failure does not roll back already generated accounts,
+ // and re-runs simply resume where they left off (idempotent).
+ if (await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false))
+ {
+ created++;
+ this._logger.LogInformation("Generated bot account '{LoginName}' with {Count} character(s).", loginName, perAccount);
+ }
+ }
+
+ return created;
+ }
+
+ ///
+ /// Deletes all bot accounts (and, by cascade, their characters and owned data).
+ ///
+ /// The cancellation token.
+ /// The number of deleted bot accounts.
+ public async ValueTask DeleteAllBotsAsync(CancellationToken cancellationToken = default)
+ {
+ using var context = this._gameContext.PersistenceContextProvider.CreateNewPlayerContext(this._gameContext.Configuration);
+ var deleted = 0;
+ const int pageSize = 100;
+ var skip = 0;
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var page = (await context.GetAccountsOrderedByLoginNameAsync(skip, pageSize, cancellationToken).ConfigureAwait(false)).ToList();
+ if (page.Count == 0)
+ {
+ break;
+ }
+
+ var bots = page.Where(a => a.IsBot).ToList();
+ var removed = 0;
+ foreach (var bot in bots)
+ {
+ if (await context.DeleteAsync(bot).ConfigureAwait(false))
+ {
+ deleted++;
+ removed++;
+ }
+ else
+ {
+ // Not silent: a bot account which survives the reset is spawned again right after
+ // it, and the paging below would never look at it a second time.
+ this._logger.LogWarning("Bot account '{LoginName}' could not be deleted for the bot reset.", bot.LoginName);
+ }
+ }
+
+ // Commit this page's deletions before paging on, so the ordering used by the next query
+ // reflects the removals: what is left of this page now occupies [skip, skip + kept).
+ if (removed > 0)
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ skip += page.Count - removed;
+ }
+
+ return deleted;
+ }
+
+ private static byte[] CreateDefaultKeyConfiguration()
+ {
+ // Mirrors CreateCharacterAction: bind Q to the healing potion and W to the mana potion,
+ // leave E and R unbound. An all-zero blob would otherwise bind the apple (a heal) to all slots.
+ const byte healingPotion = 1;
+ const byte manaPotion = 4;
+ const byte unbound = 0xFF;
+
+ var keyConfiguration = new byte[30];
+ keyConfiguration[21] = healingPotion; // Q
+ keyConfiguration[22] = manaPotion; // W
+ keyConfiguration[23] = unbound; // E
+ keyConfiguration[25] = unbound; // R
+ return keyConfiguration;
+ }
+
+ ///
+ /// Builds a shuffled queue of character classes with even quotas across ,
+ /// so the generated population is balanced instead of relying on the variance of independent random
+ /// draws. The order is randomized so accounts do not get a predictable class pattern.
+ ///
+ private static Queue BuildBalancedClassQueue(IList classes, int total)
+ {
+ var pool = new List(total);
+ for (var n = 0; n < total; n++)
+ {
+ // Even quotas: class index cycles, so each class appears total/count times (+1 for the first remainder classes).
+ pool.Add(classes[n % classes.Count]);
+ }
+
+ // Fisher-Yates shuffle so the balanced pool is handed out in random order.
+ for (var n = pool.Count - 1; n > 0; n--)
+ {
+ var j = Rand.NextInt(0, n + 1);
+ (pool[n], pool[j]) = (pool[j], pool[n]);
+ }
+
+ return new Queue(pool);
+ }
+
+ ///
+ /// Spends the character's level-up points, so a high-level bot actually has high-level stats.
+ /// Without this a generated level-80 bot would fight with level-1 base stats (tiny health and
+ /// damage) and die instantly. The split follows the class build in
+ /// for the server's meta profile (reset vs classic) - the same split the bot keeps using for
+ /// points it earns at runtime - and respects each stat's configured maximum (fun servers) as
+ /// well as the bot's personal vitality target on reset-meta servers.
+ ///
+ private static void DistributeStatPoints(Character character, CharacterClass characterClass, bool resetMeta)
+ {
+ var points = character.LevelUpPoints;
+ if (points <= 0)
+ {
+ return;
+ }
+
+ var weights = BotProgression.GetStatWeights(characterClass, character.Name, resetMeta);
+ var vitalityTarget = resetMeta ? BotProgression.GetVitalityTarget(character.Name) : (int?)null;
+
+ long CapacityOf(AttributeDefinition stat)
+ {
+ var attribute = character.Attributes.FirstOrDefault(a => a.Definition == stat);
+ if (attribute is null)
+ {
+ return 0;
+ }
+
+ var classBase = characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == stat);
+ var capacity = long.MaxValue;
+ if (classBase?.Attribute?.MaximumValue is { } maximumValue)
+ {
+ capacity = (long)maximumValue - (long)attribute.Value;
+ }
+
+ if (vitalityTarget is { } target && stat == Stats.BaseVitality)
+ {
+ var invested = (long)attribute.Value - (long)(classBase?.BaseValue ?? 0f);
+ capacity = Math.Min(capacity, target - invested);
+ }
+
+ return capacity;
+ }
+
+ foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights, CapacityOf))
+ {
+ var attribute = character.Attributes.FirstOrDefault(a => a.Definition == stat);
+ if (attribute is not null)
+ {
+ attribute.Value += amount;
+ character.LevelUpPoints -= amount;
+ }
+ }
+ }
+
+ ///
+ /// Calculates the level-up points a character with the given reset history would have available,
+ /// so a seeded bot invests the same total a player of that history would: the points granted by
+ /// the resets themselves (looped through , so tiers,
+ /// multipliers and the replace/add mode of the server's configuration all apply) plus the points
+ /// earned by leveling. With the level points of the
+ /// finished cycles were invested and wiped again at each reset, so only the current cycle's count;
+ /// without it every cycle's investment survived and still counts.
+ ///
+ private static int CalculateLevelUpPoints(CharacterClass characterClass, int level, int seededResets, ResetConfiguration? resetConfiguration)
+ {
+ var pointsPerLevel = (int)characterClass.StatAttributes.First(a => a.Attribute == Stats.PointsPerLevelUp).BaseValue;
+ if (seededResets <= 0 || resetConfiguration is null)
+ {
+ return (level - 1) * pointsPerLevel;
+ }
+
+ var pointsPerResetOverride = (int)(characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == Stats.PointsPerReset)?.BaseValue ?? 0f);
+ var resetPoints = 0;
+ for (var reset = 0; reset < seededResets; reset++)
+ {
+ var progression = ResetProgressionCalculator.Calculate(reset, pointsPerResetOverride, resetConfiguration);
+ resetPoints = resetConfiguration.ReplacePointsPerReset
+ ? progression.TotalPointsAfterReset
+ : resetPoints + progression.PointsForReset;
+ }
+
+ var currentCyclePoints = Math.Max(0, level - resetConfiguration.LevelAfterReset) * pointsPerLevel;
+ if (resetConfiguration.ResetStats)
+ {
+ return resetPoints + currentCyclePoints;
+ }
+
+ var firstCyclePoints = Math.Max(0, resetConfiguration.RequiredLevel - 1) * pointsPerLevel;
+ var laterCyclesPoints = (seededResets - 1) * Math.Max(0, resetConfiguration.RequiredLevel - resetConfiguration.LevelAfterReset) * pointsPerLevel;
+ return resetPoints + firstCyclePoints + laterCyclesPoints + currentCyclePoints;
+ }
+
+ private void CreateCharacter(IPlayerContext context, Account account, string name, CharacterClass characterClass, int level, byte slot, long[] experienceTable, int seededResets, ResetConfiguration? resetConfiguration)
+ {
+ // A character generated beyond the class evolution level was created as its second-generation
+ // class right away - like a player who completed the class quest long ago. Everything downstream
+ // (stat weights, skills, gear) keys off the evolved class. A character with a seeded reset
+ // history evolved in its first cycle at the latest - provided the reset's required level lies
+ // beyond the evolution level (the check below), which makes it pass the evolution on the way
+ // to its first reset regardless of its current in-cycle level.
+ var passedEvolutionInEarlierCycle = seededResets > 0 && resetConfiguration?.RequiredLevel >= BotProgression.ClassEvolutionLevel;
+ if ((level >= BotProgression.ClassEvolutionLevel || passedEvolutionInEarlierCycle)
+ && BotProgression.GetEvolutionTarget(characterClass) is { } evolvedClass)
+ {
+ characterClass = evolvedClass;
+ }
+
+ var character = context.CreateNew();
+ character.CharacterClass = characterClass;
+ character.Name = name;
+ character.CharacterSlot = slot;
+ character.CreateDate = DateTime.UtcNow;
+ character.KeyConfiguration = CreateDefaultKeyConfiguration();
+
+ foreach (var attribute in characterClass.StatAttributes.Select(a => context.CreateNew(a.Attribute, a.BaseValue)))
+ {
+ character.Attributes.Add(attribute);
+ }
+
+ character.CurrentMap = characterClass.HomeMap;
+ var spawnGate = character.CurrentMap!.ExitGates.Where(g => g.IsSpawnGate).SelectRandom();
+ if (spawnGate is not null)
+ {
+ character.PositionX = (byte)Rand.NextInt(spawnGate.X1, spawnGate.X2);
+ character.PositionY = (byte)Rand.NextInt(spawnGate.Y1, spawnGate.Y2);
+ }
+
+ var levelAttribute = character.Attributes.First(a => a.Definition == Stats.Level);
+ levelAttribute.Value = level;
+ if (seededResets > 0
+ && character.Attributes.FirstOrDefault(a => a.Definition == Stats.Resets) is { } resetsAttribute)
+ {
+ // Persisted exactly like a real player's resets (a per-character stat attribute), so the
+ // reset counter, the effective level and the reset limit all see the seeded history.
+ resetsAttribute.Value = seededResets;
+ }
+
+ character.Experience = experienceTable[Math.Min(level, experienceTable.Length - 1)];
+ character.LevelUpPoints = CalculateLevelUpPoints(characterClass, level, seededResets, resetConfiguration);
+ character.InventoryExtensions = BotInventoryExtensions;
+ DistributeStatPoints(character, characterClass, resetConfiguration is not null);
+
+ // Skills survive resets, so a seeded veteran knows everything the highest level of its past
+ // cycles unlocked - level-gated skills are checked against that level, not the current one.
+ var highestLevelReached = seededResets > 0 && resetConfiguration is not null
+ ? Math.Max(level, resetConfiguration.RequiredLevel)
+ : level;
+ this.LearnClassSkills(context, character, characterClass, highestLevelReached);
+
+ character.Inventory = context.CreateNew();
+ character.Inventory.Money = StartMoney;
+ this.EquipStarterGear(context, character, resetConfiguration is not null);
+
+ account.Characters.Add(character);
+ }
+
+ ///
+ /// Teaches the character the class skills appropriate to its level and stats - attack skills as well
+ /// as the class's own buffs and heals (e.g. elf Heal/Greater Defense/Greater Damage). Only skills the
+ /// class is qualified for are ever learned, gated by the skills' real learn requirements from the game
+ /// configuration (total energy, leadership, character level, ...) evaluated against the stats the bot
+ /// was just given - exactly the requirements a human player has to meet for the same skill.
+ ///
+ private void LearnClassSkills(IPlayerContext context, Character character, CharacterClass characterClass, int level)
+ {
+ float? GetValue(AttributeDefinition attribute)
+ {
+ if (BotProgression.TotalToBaseStat(attribute) is not { } baseStat)
+ {
+ return null;
+ }
+
+ return baseStat == Stats.Level
+ ? level
+ : character.Attributes.FirstOrDefault(a => a.Definition == baseStat)?.Value;
+ }
+
+ var learnedNumbers = new HashSet(character.LearnedSkills.Select(s => s.Skill!.Number));
+ foreach (var skill in this._gameContext.Configuration.Skills)
+ {
+ if (!BotProgression.IsBotLearnableSkill(skill)
+ || !skill.QualifiedCharacters.Contains(characterClass)
+ || !BotProgression.MeetsRequirements(skill, GetValue)
+ || !learnedNumbers.Add(skill.Number))
+ {
+ continue;
+ }
+
+ var entry = context.CreateNew();
+ entry.Skill = skill;
+ entry.Level = 0;
+ character.LearnedSkills.Add(entry);
+ }
+ }
+
+ ///
+ /// Equips the bot with a basic, class-appropriate weapon and armor set (mirrors the low-level test
+ /// account gear), so it is not naked and punching with its fists. The item level scales modestly
+ /// with the bot level for a bit more defense/damage without raising the equip requirements too high.
+ ///
+ private void EquipStarterGear(IPlayerContext context, Character character, bool resetMeta)
+ {
+ var inventory = character.Inventory!;
+ var characterClass = character.CharacterClass!;
+
+ // Data-driven so every class gets gear it is actually QUALIFIED to wear (a Dark Lord must never
+ // end up in a Pad/wizard set). We pick the most basic options (lowest DropLevel) the class can use:
+ // - a weapon from the weapon groups (0 sword, 1 axe, 2 mace, 3 spear, 4 bow, 5 staff),
+ // - the armor set whose chest piece (group 8) has the lowest DropLevel; its NUMBER identifies the set,
+ // and the equipment type is the GROUP (7 helm, 8 armor, 9 pants, 10 gloves, 11 boots).
+ // The weapon type follows the bot's BUILD (BotProgression.IsPreferredWeaponGroup - the same rule the
+ // later upgrades use), so an energy-specced Magic Gladiator starts with a staff instead of a blade.
+ // The Small Axe is qualified for almost every class, so without this filter casters and archers would
+ // all end up with one.
+ bool IsPreferredWeapon(ItemDefinition definition)
+ => BotProgression.IsPreferredWeaponGroup(characterClass, character.Name, resetMeta, (byte)definition.Group);
+
+ // Ammunition shares the bow group (Bolt/Arrows have DropLevel 0), so without this filter every
+ // archer would get a bolt stack as its "weapon" and end up punching with its fists.
+ var weapon = this._gameContext.Configuration.Items
+ .Where(d => IsPreferredWeapon(d) && !d.IsAmmunition && d.QualifiedCharacters.Contains(characterClass))
+ .MinBy(d => d.DropLevel)
+ ?? this._gameContext.Configuration.Items
+ .Where(d => d.Group <= StaffGroup && !d.IsAmmunition && d.QualifiedCharacters.Contains(characterClass))
+ .MinBy(d => d.DropLevel);
+ if (weapon is not null)
+ {
+ if (weapon.Group == BowGroup)
+ {
+ // Bows need ammunition; the arrows go into the left hand.
+ this.AddEquippedItem(context, inventory, characterClass, InventoryConstants.RightHandSlot, weapon);
+ this.AddAmmunition(context, inventory);
+ }
+ else
+ {
+ this.AddEquippedItem(context, inventory, characterClass, InventoryConstants.LeftHandSlot, weapon);
+ }
+ }
+
+ // Choose a thematically appropriate armor set the class can wear, tried in order (warriors -> Leather,
+ // wizards -> Pad, elves -> Vine, summoners -> Mistery, then fallbacks). Each piece is added only if the
+ // class is qualified for it, so e.g. the Magic Gladiator keeps the set but skips the helm it can't wear.
+ foreach (var set in ArmorSetCandidates)
+ {
+ if (this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == ArmorGroup && d.Number == set) is not { } chest
+ || !chest.QualifiedCharacters.Contains(characterClass))
+ {
+ continue;
+ }
+
+ this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.HelmSlot, 7, set);
+ this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.ArmorSlot, 8, set);
+ this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.PantsSlot, 9, set);
+ this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.GlovesSlot, 10, set);
+ this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.BootsSlot, 11, set);
+ break;
+ }
+
+ this.AddPotions(context, inventory);
+ }
+
+ private void AddPotions(IPlayerContext context, ItemStorage inventory)
+ {
+ // A stack of Large Healing Potions so the offline HealingHandler has something to drink, and a
+ // stack of Large Mana Potions so casters can keep casting instead of degrading to weak melee once
+ // their mana runs dry. The BotNavigator tops both up at runtime, so the bot never runs out.
+ // Durability holds the stack count.
+ this.AddPotionStack(context, inventory, 3, InventoryConstants.EquippableSlotsCount); // Large Healing Potion, first backpack slot
+ this.AddPotionStack(context, inventory, 6, (byte)(InventoryConstants.EquippableSlotsCount + 1)); // Large Mana Potion, second backpack slot
+ }
+
+ private void AddPotionStack(IPlayerContext context, ItemStorage inventory, byte potionNumber, byte slot)
+ {
+ var potion = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == 14 && d.Number == potionNumber);
+ if (potion is null)
+ {
+ return;
+ }
+
+ var item = context.CreateNew
- ();
+ item.Definition = potion;
+
+ // Only a handful of charges to start with: fresh bots head to the merchant right away and buy
+ // their supplies with their starting Zen, kicking off the shopping economy from minute one
+ // (kept just above the emergency top-up threshold, so the economy path - not the fallback - runs).
+ item.Durability = Rand.NextInt(10, 16);
+ item.ItemSlot = slot;
+ inventory.Items.Add(item);
+ }
+
+ private void EquipArmorPiece(IPlayerContext context, ItemStorage inventory, CharacterClass characterClass, byte slot, int group, int number)
+ {
+ var definition = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == group && d.Number == number);
+ if (definition is null || !definition.QualifiedCharacters.Contains(characterClass))
+ {
+ return;
+ }
+
+ this.AddEquippedItem(context, inventory, characterClass, slot, definition);
+ }
+
+ private void AddEquippedItem(IPlayerContext context, ItemStorage inventory, CharacterClass characterClass, byte slot, ItemDefinition definition)
+ {
+ if (!definition.QualifiedCharacters.Contains(characterClass))
+ {
+ return;
+ }
+
+ var item = context.CreateNew
- ();
+ item.Definition = definition;
+ item.Level = StarterItemLevel;
+ item.Durability = definition.Durability;
+ item.ItemSlot = slot;
+ inventory.Items.Add(item);
+ }
+
+ private void AddAmmunition(IPlayerContext context, ItemStorage inventory)
+ {
+ var arrows = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == 4 && d.Number == 15);
+ if (arrows is null)
+ {
+ return;
+ }
+
+ var item = context.CreateNew
- ();
+ item.Definition = arrows;
+ item.Durability = 255;
+ item.ItemSlot = InventoryConstants.LeftHandSlot;
+ inventory.Items.Add(item);
+ }
+}
diff --git a/src/GameLogic/Bots/BotJewelHandler.cs b/src/GameLogic/Bots/BotJewelHandler.cs
new file mode 100644
index 000000000..bbca39986
--- /dev/null
+++ b/src/GameLogic/Bots/BotJewelHandler.cs
@@ -0,0 +1,228 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.GameLogic.Offline;
+using MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions;
+using MUnique.OpenMU.GameLogic.PlayerActions.Items;
+
+///
+/// Lets a bot invest its looted jewels into its own gear like a real player would: after a shopping
+/// trip (a rare, safe moment in town), it takes a piece of equipment off, applies a Jewel of Bless,
+/// Soul or Life through the regular - the same validations, success
+/// rates and failure penalties as for a human - and puts the piece back on.
+/// The policy mirrors common player behavior: Bless (always succeeds) pushes the weakest equipped
+/// piece towards +6, whether it has luck or not; Soul (50%, +25% with luck; a failure at +6 drops
+/// the item to +5, from +7 on it resets it to +0) is only risked with a spare in stock, on plain
+/// items only at +6 and up to +9 only on lucky ones; Life (50%, removes the option on failure) is
+/// used sparingly on already upgraded gear. Only a couple of jewels are spent per trip, so the
+/// upgrades trickle in over many visits like for a player instead of the whole hoard being burned
+/// at once.
+///
+internal static class BotJewelHandler
+{
+ /// Upper bound of jewel consumptions per shopping trip - a player doesn't burn the whole hoard at once.
+ private const int MaxUsesPerTrip = 2;
+
+ /// The Jewel of Bless upgrades item levels 0..5 (see BlessJewelConsumeHandlerPlugIn).
+ private const byte BlessMaxTargetLevel = 5;
+
+ /// Souls are never spent below +6 - that range is Bless territory (safe and cheap).
+ private const byte SoulMinTargetLevel = 6;
+
+ ///
+ /// Without luck a Soul is only risked at +6, where a failure merely drops the item to +5 (a Bless
+ /// restores that): from +7 on, a failed Soul resets the item to +0
+ /// (ResetToLevel0WhenFailMinLevel in SoulJewelConsumeHandlerPlugIn), and at the base
+ /// 50% success rate that gamble wipes gear more often than not.
+ ///
+ private const byte SoulMaxTargetLevelPlain = 6;
+
+ ///
+ /// With luck (+25% success) the Soul may be risked up to +8, so lucky items can reach the jewel
+ /// ceiling of +9 (MaximumLevel in SoulJewelConsumeHandlerPlugIn).
+ ///
+ private const byte SoulMaxTargetLevelLucky = 8;
+
+ /// Only risk a Soul with at least this many in stock - one failure must not wipe out the reserve.
+ private const int MinSoulStock = 2;
+
+ /// Life is only worth risking on gear which already proved worth upgrading.
+ private const byte LifeMinTargetLevel = 6;
+
+ /// Only risk a Life with at least this many in stock.
+ private const int MinLifeStock = 2;
+
+ private static readonly ItemConsumeAction ConsumeAction = new();
+ private static readonly MoveItemAction MoveAction = new();
+
+ ///
+ /// Spends up to looted jewels on the bot's own equipment. Call this
+ /// right after a finished merchant trade: the bot stands in the safezone, the NPC dialog is closed
+ /// (player state is back at EnteredWorld, which the consume handlers require), and the
+ /// navigator's shopping cooldown provides the rare, player-like cadence.
+ ///
+ /// The bot player.
+ public static async ValueTask TryUpgradeGearAsync(OfflinePlayer player)
+ {
+ if (player.Inventory is null)
+ {
+ return;
+ }
+
+ var uses = 0;
+ var lifeUsed = false;
+ while (uses < MaxUsesPerTrip && PlanNextUse(player, lifeUsed) is { } plan)
+ {
+ if (!await ApplyJewelAsync(player, plan.Jewel, plan.Target).ConfigureAwait(false))
+ {
+ break;
+ }
+
+ uses++;
+ lifeUsed |= plan.IsLife;
+ }
+
+ if (uses > 0)
+ {
+ try
+ {
+ // Persist right away like after a bot reset - a rolled Soul/Life outcome shouldn't be
+ // replayable by losing it to a crash before the next periodic save.
+ await player.SaveProgressAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after using jewels; the periodic save will retry.", player.Name);
+ }
+ }
+ }
+
+ ///
+ /// Picks the next (jewel, equipped target item) pair according to the player-like policy, or
+ /// null when nothing sensible is left to do. Pure decision logic - exposed for unit tests.
+ ///
+ /// The bot player.
+ /// Whether a Jewel of Life was already used this trip (at most one).
+ internal static (Item Jewel, Item Target, bool IsLife)? PlanNextUse(Player player, bool lifeUsed)
+ {
+ if (player.Inventory is not { } inventory)
+ {
+ return null;
+ }
+
+ var backpack = inventory.Items.Where(i => i.ItemSlot > InventoryConstants.LastEquippableItemSlotIndex).ToList();
+ var equipped = inventory.Items
+ .Where(i => i.ItemSlot <= InventoryConstants.LastEquippableItemSlotIndex
+ && i.Definition?.IsAmmunition == false)
+ .ToList();
+
+ var blessStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfBless)).ToList();
+ var soulStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfSoul)).ToList();
+ var lifeStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfLife)).ToList();
+
+ // 1. Bless - free progress: push the weakest equipped piece towards +6.
+ if (blessStock.Count > 0
+ && equipped.Where(i => i.CanLevelBeUpgraded() && i.Level <= BlessMaxTargetLevel)
+ .OrderBy(i => i.Level)
+ .FirstOrDefault() is { } blessTarget)
+ {
+ return (blessStock[0], blessTarget, false);
+ }
+
+ // 2. Soul - risky: only with a spare in stock, and only where the possible loss is bearable -
+ // items without luck stop at +6 -> +7 (see SoulMaxTargetLevelPlain), lucky ones may go for +9.
+ if (soulStock.Count >= MinSoulStock
+ && equipped.Where(i => i.CanLevelBeUpgraded()
+ && i.Level >= SoulMinTargetLevel
+ && i.Level <= (HasLuck(i) ? SoulMaxTargetLevelLucky : SoulMaxTargetLevelPlain))
+ .OrderByDescending(HasLuck)
+ .ThenBy(i => i.Level)
+ .FirstOrDefault() is { } soulTarget)
+ {
+ return (soulStock[0], soulTarget, false);
+ }
+
+ // 3. Life - sparingly: at most one per trip, only on gear that is already +6 or better. Whether
+ // the item can actually carry the option is the consume handler's call; a rejected consume
+ // keeps the jewel.
+ if (!lifeUsed
+ && lifeStock.Count >= MinLifeStock
+ && equipped.Where(i => i.IsWearable() && i.Level >= LifeMinTargetLevel)
+ .OrderByDescending(i => i.Level)
+ .FirstOrDefault() is { } lifeTarget)
+ {
+ return (lifeStock[0], lifeTarget, true);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Applies one jewel to one equipped item the way a player does it: take the piece off into a free
+ /// backpack slot (the consume handlers refuse to modify equipped items), consume the jewel on it
+ /// through the regular action, and wear the piece again - whatever the outcome, even a Soul
+ /// failure's downgraded item goes back on.
+ ///
+ /// true, if the jewel was actually consumed.
+ private static async ValueTask ApplyJewelAsync(OfflinePlayer player, Item jewel, Item target)
+ {
+ var inventory = player.Inventory!;
+ var equipSlot = target.ItemSlot;
+
+ // A free slot is not enough - the piece needs a hole of its SIZE (a 2x3 armor does not fit into
+ // the 1x1 gap a jewel left behind), which is exactly what CheckInvSpace answers.
+ if (inventory.CheckInvSpace(target) is not { } freeSlot
+ || freeSlot <= InventoryConstants.LastEquippableItemSlotIndex)
+ {
+ return false;
+ }
+
+ await MoveAction.MoveItemAsync(player, equipSlot, Storages.Inventory, freeSlot, Storages.Inventory).ConfigureAwait(false);
+ if (inventory.GetItem(freeSlot) != target)
+ {
+ // The unequip was rejected - don't force it.
+ return false;
+ }
+
+ var jewelSlot = jewel.ItemSlot;
+ var levelBefore = target.Level;
+ try
+ {
+ await ConsumeAction.HandleConsumeRequestAsync(player, jewelSlot, freeSlot, FruitUsage.Undefined).ConfigureAwait(false);
+ }
+ finally
+ {
+ // Wear the piece again in any case; the move action re-checks the requirements itself.
+ await MoveAction.MoveItemAsync(player, freeSlot, Storages.Inventory, equipSlot, Storages.Inventory).ConfigureAwait(false);
+ }
+
+ var consumed = inventory.GetItem(jewelSlot) != jewel;
+ if (consumed)
+ {
+ player.Logger.LogInformation(
+ "Bot '{Name}' used '{Jewel}' on '{Item}': level {Before} -> {After}.",
+ player.Name,
+ jewel.Definition?.Name,
+ target,
+ levelBefore,
+ target.Level);
+ }
+
+ return consumed;
+ }
+
+ private static bool IsJewel(Item item, ItemIdentifier identifier)
+ {
+ return item.Definition is { } definition
+ && identifier == new ItemIdentifier(definition.Number, definition.Group);
+ }
+
+ private static bool HasLuck(Item item)
+ {
+ return item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Luck);
+ }
+}
diff --git a/src/GameLogic/Bots/BotManager.cs b/src/GameLogic/Bots/BotManager.cs
new file mode 100644
index 000000000..f9bf7d6ef
--- /dev/null
+++ b/src/GameLogic/Bots/BotManager.cs
@@ -0,0 +1,310 @@
+//
+// 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.Linq;
+using MUnique.OpenMU.GameLogic.Offline;
+
+///
+/// Manages the lifecycle of server-side bots.
+///
+///
+/// A bot reuses the connection-less together with its MU Helper AI,
+/// but is spawned in a fully standalone way: the account is loaded fresh in the bot's own
+/// persistence context (see ), so there is no
+/// cross-context attach of entities owned by another player - which is the root cause of the
+/// known data-corruption issue of the /offlevel handover. Because each bot drives a
+/// distinct character in its own context, several bots can animate different characters of the
+/// same account at once (the shared account row is only attached, never modified).
+///
+public sealed class BotManager
+{
+ private readonly ConcurrentDictionary _bots = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Gets a snapshot of the currently active bots.
+ ///
+ public IReadOnlyCollection Bots => this._bots.Values.ToList();
+
+ ///
+ /// Spawns a bot which drives a specific character of the given account.
+ ///
+ /// The game context.
+ /// The login name of an existing account.
+ /// The character slot to drive; null drives the first character by slot.
+ /// true if a bot was started; false if it could not be started or was already active.
+ public async ValueTask SpawnBotAsync(IGameContext gameContext, string loginName, byte? characterSlot = null)
+ {
+ if (string.IsNullOrWhiteSpace(loginName))
+ {
+ return false;
+ }
+
+ var bot = new BotPlayer(gameContext);
+ var added = false;
+ string? key = null;
+ try
+ {
+ // Load the account through the bot's OWN persistence context (no cross-context attach).
+ var account = await bot.PersistenceContext.GetAccountByLoginNameAsync(loginName).ConfigureAwait(false);
+ var character = characterSlot is { } slot
+ ? account?.Characters.FirstOrDefault(c => c.CharacterSlot == slot)
+ : account?.Characters.OrderBy(c => c.CharacterSlot).FirstOrDefault();
+ if (account is null || character is null)
+ {
+ bot.Logger.LogWarning("Bot account '{LoginName}' (slot {Slot}) could not be loaded or has no character.", loginName, characterSlot);
+ await bot.DisposeAsync().ConfigureAwait(false);
+ return false;
+ }
+
+ key = GetKey(loginName, character.CharacterSlot);
+ if (!this._bots.TryAdd(key, bot))
+ {
+ // Already animating this character.
+ await bot.DisposeAsync().ConfigureAwait(false);
+ return false;
+ }
+
+ added = true;
+
+ // Provide AI settings so the bot actually hunts (a missing config means a single-tile range).
+ bot.MuHelperSettings = new BotMuHelperSettings();
+
+ if (!await bot.InitializeAsync(loginName, character.Name).ConfigureAwait(false))
+ {
+ await this.RemoveAndDisposeAsync(key, bot).ConfigureAwait(false);
+ return false;
+ }
+
+ BotSkillProgressionPlugIn.CatchUpPendingProgress(bot);
+
+ bot.Logger.LogInformation("Bot started for account '{LoginName}', character '{Character}'.", loginName, character.Name);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ bot.Logger.LogError(ex, "Failed to spawn bot for account '{LoginName}' (slot {Slot}).", loginName, characterSlot);
+ if (added && key is not null)
+ {
+ await this.RemoveAndDisposeAsync(key, bot).ConfigureAwait(false);
+ }
+ else
+ {
+ await bot.DisposeAsync().ConfigureAwait(false);
+ }
+
+ return false;
+ }
+ }
+
+ ///
+ /// Stops and removes all currently active bots.
+ ///
+ /// The task.
+ public async ValueTask StopAllAsync()
+ {
+ foreach (var key in this._bots.Keys.ToList())
+ {
+ if (this._bots.TryRemove(key, out var bot))
+ {
+ await StopAndDisposeAsync(bot, key, "shutdown").ConfigureAwait(false);
+ }
+ }
+ }
+
+ ///
+ /// Determines whether the given character of the given account is currently animated by a bot.
+ ///
+ /// The account login name.
+ /// The character slot.
+ public bool IsActive(string loginName, byte slot) => this._bots.ContainsKey(GetKey(loginName, slot));
+
+ ///
+ /// Stops one randomly chosen bot (used by the presence rotation, so the population ebbs and flows
+ /// like a real player base). The bot leaves its party cleanly first, then disconnects - which also
+ /// saves its progress, like a regular logout.
+ ///
+ /// The name of the stopped bot's character, or null if no bot was active.
+ public async ValueTask StopRandomBotAsync()
+ {
+ var key = this._bots.Keys.ToList().SelectRandom();
+ if (key is null || !this._bots.TryRemove(key, out var bot))
+ {
+ return null;
+ }
+
+ var name = bot.Name;
+ try
+ {
+ if (bot.Party is { } party)
+ {
+ await party.KickMySelfAsync(bot).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ // A failed party goodbye must not skip the stop below - the party cleans up a
+ // disconnected member itself.
+ bot.Logger.LogWarning(ex, "Bot '{Key}' couldn't leave its party for the presence rotation.", key);
+ }
+
+ await StopAndDisposeAsync(bot, key, "presence rotation").ConfigureAwait(false);
+ return name;
+ }
+
+ ///
+ /// Stops the given bot like a regular logout and immediately brings the same character back
+ /// online - the ghost equivalent of a player relogging. Used after the master evolution: the
+ /// master class's base attributes (master experience rate, master points per level) and the
+ /// master level stat are only mounted when the character enters the world, so the class change
+ /// must be followed by a fresh world entry before master experience can flow.
+ ///
+ /// The game context.
+ /// The bot to restart.
+ /// true if the bot came back online.
+ public async ValueTask RestartBotAsync(IGameContext gameContext, BotPlayer bot)
+ {
+ // Captured before the stop - the disposed bot loses its account and character.
+ var loginName = bot.Account?.LoginName;
+ var characterSlot = bot.SelectedCharacter?.CharacterSlot;
+ if (loginName is null
+ || characterSlot is not { } slot
+ || !this._bots.TryRemove(GetKey(loginName, slot), out var removed))
+ {
+ return false;
+ }
+
+ if (removed.Party is { } party)
+ {
+ try
+ {
+ await party.KickMySelfAsync(removed).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // A failed party goodbye must not skip the stop below - the party cleans up a
+ // disconnected member itself.
+ removed.Logger.LogWarning(ex, "Bot '{Login}/{Slot}' couldn't leave its party for the restart.", loginName, slot);
+ }
+ }
+
+ try
+ {
+ await removed.StopAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // The old instance may still be (partially) alive - put it back under management and
+ // don't spawn a second player driving the same character (two persistence contexts
+ // saving one character would be last-write-wins data loss). Retried on a later pass.
+ removed.Logger.LogError(ex, "Error while stopping bot '{Login}/{Slot}' for a restart; keeping the old instance.", loginName, slot);
+ this._bots.TryAdd(GetKey(loginName, slot), removed);
+ return false;
+ }
+
+ // The stopped instance is done for good - release its persistence context and the tracked
+ // account graph before the fresh one loads them again.
+ await removed.DisposeAsync().ConfigureAwait(false);
+
+ return await this.SpawnBotAsync(gameContext, loginName, slot).ConfigureAwait(false);
+ }
+
+ ///
+ /// Groups a share of the active bots into small hunting parties of level-wise similar characters,
+ /// like real players do: the party members follow their leader (see the follow logic in
+ /// ), the elf heals the group, buffs are shared and the party experience
+ /// bonus applies. The rest of the bots keep hunting solo, so the population stays varied.
+ ///
+ /// The game context (provides the party manager).
+ public async ValueTask FormPartiesAsync(IGameContext gameContext)
+ {
+ const int minPartySize = 2;
+ const int maxPartySize = 5;
+ const int maxLevelGap = 12;
+ const int partiedSharePercent = 60;
+
+ // Matched by the reset-aware effective level (see BotResetHandler.GetEffectiveLevel), so on a
+ // reset server a freshly reset veteran groups with its peers instead of with real newbies.
+ var candidates = this._bots.Values
+ .Where(b => b.Party is null && b.Attributes is not null)
+ .OrderBy(BotResetHandler.GetEffectiveLevel)
+ .ToList();
+
+ var index = 0;
+ while (index < candidates.Count - 1)
+ {
+ if (Rand.NextInt(0, 100) >= partiedSharePercent)
+ {
+ index++; // this bot stays solo
+ continue;
+ }
+
+ var leader = candidates[index];
+ var leaderLevel = BotResetHandler.GetEffectiveLevel(leader);
+ var targetSize = Rand.NextInt(minPartySize, maxPartySize + 1);
+ var members = new List { leader };
+ var next = index + 1;
+ while (next < candidates.Count
+ && members.Count < targetSize
+ && BotResetHandler.GetEffectiveLevel(candidates[next]) - leaderLevel <= maxLevelGap)
+ {
+ members.Add(candidates[next]);
+ next++;
+ }
+
+ if (members.Count >= minPartySize)
+ {
+ var party = gameContext.PartyManager.CreateParty();
+ foreach (var member in members)
+ {
+ if (!await party.AddAsync(member).ConfigureAwait(false))
+ {
+ break;
+ }
+ }
+
+ leader.Logger.LogInformation(
+ "Formed bot party of {Count} around '{Leader}' (level {Level}).",
+ members.Count,
+ leader.Name,
+ leaderLevel);
+ }
+
+ index = next;
+ }
+ }
+
+ private static string GetKey(string loginName, byte slot) => $"{loginName}/{slot}";
+
+ ///
+ /// Stops the bot like a regular logout (which saves its progress) and releases its resources.
+ /// A stopped-but-not-disposed player keeps its persistence context - and with it the whole tracked
+ /// account graph - alive; with the presence rotation stopping bots around the clock, that adds up
+ /// to a leak. Mirrors the teardown of the ; the removal
+ /// from the game context happens through the disconnect event.
+ ///
+ private static async ValueTask StopAndDisposeAsync(BotPlayer bot, string key, string reason)
+ {
+ try
+ {
+ await bot.StopAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ bot.Logger.LogError(ex, "Error while stopping bot '{Key}' ({Reason}).", key, reason);
+ }
+ finally
+ {
+ await bot.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ private async ValueTask RemoveAndDisposeAsync(string key, BotPlayer bot)
+ {
+ this._bots.TryRemove(key, out _);
+ await bot.DisposeAsync().ConfigureAwait(false);
+ }
+}
diff --git a/src/GameLogic/Bots/BotMasterHandler.cs b/src/GameLogic/Bots/BotMasterHandler.cs
new file mode 100644
index 000000000..0da3d5f7c
--- /dev/null
+++ b/src/GameLogic/Bots/BotMasterHandler.cs
@@ -0,0 +1,351 @@
+//
+// 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;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.Offline;
+using MUnique.OpenMU.GameLogic.PlayerActions.Character;
+
+///
+/// Handles the third-generation ("master") stage of a bot's career: the evolution into the master
+/// class at the game's maximum level - the same class assignment the level-400 master quests perform
+/// for a human player - and the investment of the master points earned per master level, through the
+/// regular with all its validations.
+///
+///
+/// On servers with the reset feature the evolution follows one iron rule: a bot only becomes a master
+/// when no reset can ever follow, i.e. the configured reset limit is exhausted. While resets remain -
+/// or when no limit is configured at all, so resetting forever is the endgame - the bot keeps resetting
+/// and never masters, like the players of such servers. Without the reset feature it evolves as soon as
+/// it reaches the maximum level.
+/// The master class's base attributes (master experience rate, master points per level) and the master
+/// level stat are only mounted into the attribute system when the character enters the world, so the
+/// caller must restart the bot after the evolution (see BotManager.RestartBotAsync) - the ghost
+/// equivalent of a player relogging; master experience only flows after that fresh world entry.
+///
+internal static class BotMasterHandler
+{
+ ///
+ /// A master skill unlocks the next rank of its root (and satisfies "required skill" links) at this
+ /// level, see .
+ ///
+ private const int RankUnlockLevel = 10;
+
+ /// The item groups of the weapons a master bonus can be tied to (see ).
+ private const byte SwordGroup = 0;
+
+ /// The item group of the maces - the scepters live here as well.
+ private const byte MaceGroup = 2;
+
+ /// The item group of the spears.
+ private const byte SpearGroup = 3;
+
+ /// The item group of the bows and crossbows.
+ private const byte BowGroup = 4;
+
+ /// The item group of the staffs - the sticks and books live here as well.
+ private const byte StaffGroup = 5;
+
+ private static readonly AddMasterPointAction AddPointAction = new();
+
+ ///
+ /// Determines whether the bot is due for its master evolution: it has a master class to evolve
+ /// into, stands at the game's maximum level, and - on reset servers - exhausted the reset limit
+ /// (see the remarks of for the rationale).
+ ///
+ /// The bot player.
+ /// True, if the bot should evolve into its master class now.
+ public static bool IsMasterEvolutionDue(Player player)
+ {
+ if (player.SelectedCharacter is not { CharacterClass: { } currentClass }
+ || player.Attributes is not { } attributes
+ || BotProgression.GetMasterEvolutionTarget(currentClass) is null)
+ {
+ return false;
+ }
+
+ if ((int)attributes[Stats.Level] < player.GameContext.Configuration.MaximumLevel)
+ {
+ return false;
+ }
+
+ if (BotResetHandler.GetResetConfiguration(player.GameContext) is { } resetConfiguration
+ && (resetConfiguration.ResetLimit is not > 0
+ || (int)attributes[Stats.Resets] < resetConfiguration.ResetLimit))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Evolves the bot into its master class when due. The caller must restart the bot afterwards
+ /// (see the remarks of ).
+ ///
+ /// The bot player.
+ /// True, if the evolution was performed.
+ public static async ValueTask TryEvolveAsync(OfflinePlayer player)
+ {
+ if (!IsMasterEvolutionDue(player) || player.SelectedCharacter is not { } character)
+ {
+ return false;
+ }
+
+ var masterClass = BotProgression.GetMasterEvolutionTarget(character.CharacterClass!)!;
+ character.CharacterClass = masterClass;
+ player.Logger.LogInformation(
+ "Bot '{Name}' evolved into master class {Class} at level {Level}.",
+ player.Name,
+ masterClass.Name,
+ player.Level);
+
+ try
+ {
+ // Persist right away like a performed reset - the following restart reloads the character
+ // from the database, so the class change must be down there before it.
+ await player.SaveProgressAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after its master evolution; the logout save will retry.", player.Name);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Determines whether the bot is a master with points to invest - the cheap per-tick guard for
+ /// queueing .
+ ///
+ /// The bot player.
+ /// True, if there are master points to spend.
+ public static bool HasMasterPointsToSpend(Player player)
+ => player.SelectedCharacter is { CharacterClass.IsMasterClass: true, MasterLevelUpPoints: > 0 };
+
+ ///
+ /// Invests the bot's available master points (earned one per master level) into its master skill
+ /// tree through the regular . Learning a skill mutates the
+ /// skill list, so this must run inside the bot's AI tick - queue it via
+ /// (see the call site in BotNavigator);
+ /// with no points available it is a cheap no-op.
+ ///
+ /// The bot player.
+ public static async ValueTask TrySpendMasterPointsAsync(OfflinePlayer player)
+ {
+ if (player.SelectedCharacter is not { CharacterClass.IsMasterClass: true } character
+ || character.MasterLevelUpPoints < 1)
+ {
+ return;
+ }
+
+ while (character.MasterLevelUpPoints > 0 && PickNextMasterSkill(player) is { } skill)
+ {
+ var pointsBefore = character.MasterLevelUpPoints;
+ await AddPointAction.AddMasterPointAsync(player, (ushort)skill.Number).ConfigureAwait(false);
+ if (character.MasterLevelUpPoints >= pointsBefore)
+ {
+ // The action refused - its own checks are authoritative, don't loop on the same pick.
+ break;
+ }
+
+ player.Logger.LogInformation(
+ "Bot '{Name}' invested {Points} master point(s) into '{Skill}'.",
+ player.Name,
+ pointsBefore - character.MasterLevelUpPoints,
+ skill.Name);
+ }
+ }
+
+ ///
+ /// Picks the master skill the bot invests its next point into, or null when nothing is
+ /// eligible. The policy fills the tree like a player: first push a started skill to the rank-unlock
+ /// level of 10, then learn a new eligible skill - preferring "useful" ones, i.e. passives boosting a
+ /// stat or strengtheners of a skill the bot actually has - and finally pump the learned skills
+ /// towards their maximum. Deterministic, so a bot builds the same tree across sessions.
+ /// Pure decision logic - exposed for unit tests.
+ ///
+ /// The bot player.
+ internal static Skill? PickNextMasterSkill(Player player)
+ {
+ if (player.SelectedCharacter is not { CharacterClass: { } characterClass } character
+ || player.SkillList is not { } skillList)
+ {
+ return null;
+ }
+
+ var learned = character.LearnedSkills
+ .Where(l => l.Skill?.MasterDefinition?.Root is not null)
+ .ToList();
+
+ if (learned
+ .Where(l => l.Level < RankUnlockLevel && l.Level < l.Skill!.MasterDefinition!.MaximumLevel)
+ .OrderBy(l => l.Skill!.MasterDefinition!.Rank)
+ .ThenBy(l => l.Skill!.Number)
+ .FirstOrDefault() is { } gate)
+ {
+ return gate.Skill;
+ }
+
+ if (player.GameContext.Configuration.Skills
+ .Where(s => s.MasterDefinition?.Root is not null
+ && s.QualifiedCharacters.Contains(characterClass)
+ && character.LearnedSkills.All(l => l.Skill != s)
+ && CanLearn(player, s, character.MasterLevelUpPoints))
+ .OrderBy(s => IsUsefulPick(player, s, skillList) ? 0 : 1)
+ .ThenBy(s => s.MasterDefinition!.Rank)
+ .ThenBy(s => s.Number)
+ .FirstOrDefault() is { } newSkill)
+ {
+ return newSkill;
+ }
+
+ return learned
+ .Where(l => l.Level < l.Skill!.MasterDefinition!.MaximumLevel)
+ .OrderBy(l => IsUsefulPick(player, l.Skill!, skillList) ? 0 : 1)
+ .ThenBy(l => l.Skill!.MasterDefinition!.Rank)
+ .ThenBy(l => l.Skill!.Number)
+ .FirstOrDefault()?.Skill;
+ }
+
+ ///
+ /// Mirrors the private requisition checks of (minimum points,
+ /// previous rank of the same root at 10+, required skills), so the picker only proposes skills the
+ /// action will accept. A mismatch is harmless: the action refuses and the spend loop stops.
+ ///
+ private static bool CanLearn(Player player, Skill skill, int availablePoints)
+ {
+ var definition = skill.MasterDefinition!;
+ if (availablePoints < definition.MinimumLevel)
+ {
+ return false;
+ }
+
+ if (definition.Rank > 1
+ && !player.SelectedCharacter!.LearnedSkills.Any(l =>
+ l.Skill?.MasterDefinition?.Root is { } root
+ && root.Id == definition.Root?.Id
+ && l.Skill.MasterDefinition.Rank == definition.Rank - 1
+ && l.Level >= RankUnlockLevel))
+ {
+ return false;
+ }
+
+ if (definition.RequiredMasterSkills?.Any() == true
+ && !definition.RequiredMasterSkills.All(s =>
+ player.SelectedCharacter!.LearnedSkills.Any(l => l.Skill == s && l.Level >= RankUnlockLevel)
+ || (s.MasterDefinition is null && player.SkillList?.ContainsSkill((ushort)s.Number) == true)))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// A pick is "useful" when it demonstrably does something for this bot: a passive boosting a stat,
+ /// or a strengthener/mastery of a skill the bot actually has in its list. A passive tied to a WEAPON
+ /// type the bot does not fight with is not (a bow strengthener does nothing for a bot swinging a
+ /// sword), and neither is one which only applies against other PLAYERS: a bot spends its life
+ /// hunting monsters, and it may not even attack a player unless attacked first (see
+ /// ). Both get filled last, after everything which actually helps.
+ ///
+ private static bool IsUsefulPick(Player player, Skill skill, ISkillList skillList)
+ {
+ var definition = skill.MasterDefinition!;
+ if (definition.TargetAttribute is { } target)
+ {
+ return !IsPvpOnlyBonus(target)
+ && (WeaponGroupOfBonus(target) is not { } weaponGroup || CarriesWeaponOfGroup(player, weaponGroup));
+ }
+
+ return definition.ReplacedSkill is { } replaced && skillList.ContainsSkill((ushort)replaced.Number);
+ }
+
+ ///
+ /// Whether the master bonus only ever applies in a fight against another player, which is not what a
+ /// bot's points are for.
+ ///
+ /// The bonus attribute.
+ private static bool IsPvpOnlyBonus(AttributeDefinition attribute)
+ {
+ return attribute == Stats.AttackRatePvp || attribute == Stats.DefenseRatePvp;
+ }
+
+ ///
+ /// The item group of the weapon a master bonus attribute belongs to, or null when the bonus
+ /// helps regardless of the weapon (health, defense, an attack rate, ...). The item groups come from
+ /// the item data: scepters live in the mace group, the Rage Fighter's gloves in the sword group, and
+ /// sticks and books next to the staffs.
+ ///
+ private static byte? WeaponGroupOfBonus(AttributeDefinition attribute)
+ {
+ if (attribute == Stats.OneHandedSwordBonusDamage
+ || attribute == Stats.TwoHandedSwordStrBonusDamage
+ || attribute == Stats.TwoHandedSwordMasteryBonusDamage
+ || attribute == Stats.GloveWeaponBonusDamage)
+ {
+ return SwordGroup;
+ }
+
+ if (attribute == Stats.MaceBonusDamage
+ || attribute == Stats.MaceMasteryStunChance
+ || attribute == Stats.ScepterStrBonusDamage
+ || attribute == Stats.ScepterMasteryBonusDamage
+ || attribute == Stats.ScepterPetBonusDamage
+ || attribute == Stats.BonusDamageWithScepterCmdDiv)
+ {
+ return MaceGroup;
+ }
+
+ if (attribute == Stats.SpearBonusDamage
+ || attribute == Stats.SpearMasteryDoubleDamageChance)
+ {
+ return SpearGroup;
+ }
+
+ if (attribute == Stats.BowStrBonusDamage
+ || attribute == Stats.CrossBowStrBonusDamage
+ || attribute == Stats.CrossBowMasteryBonusDamage)
+ {
+ return BowGroup;
+ }
+
+ if (attribute == Stats.OneHandedStaffBonusBaseDamage
+ || attribute == Stats.TwoHandedStaffBonusBaseDamage
+ || attribute == Stats.TwoHandedStaffMasteryBonusDamage
+ || attribute == Stats.StickBonusBaseDamage
+ || attribute == Stats.StickMasteryBonusDamage
+ || attribute == Stats.BookBonusBaseDamage)
+ {
+ return StaffGroup;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Whether the bot fights with a weapon of this item group - what it carries right now, and what its
+ /// build makes it pick up in the future (see ), so
+ /// a bot which is momentarily unarmed does not start collecting bonuses for the wrong weapon.
+ ///
+ private static bool CarriesWeaponOfGroup(Player player, byte weaponGroup)
+ {
+ if (player.Inventory?.GetItem(InventoryConstants.LeftHandSlot)?.Definition is { } weapon
+ && weapon.Group == weaponGroup)
+ {
+ return true;
+ }
+
+ return player.SelectedCharacter is { CharacterClass: { } characterClass } character
+ && BotProgression.IsPreferredWeaponGroup(
+ characterClass,
+ character.Name,
+ BotResetHandler.GetResetConfiguration(player.GameContext) is not null,
+ weaponGroup);
+ }
+}
diff --git a/src/GameLogic/Bots/BotMiniGameHandler.cs b/src/GameLogic/Bots/BotMiniGameHandler.cs
new file mode 100644
index 000000000..f811562e8
--- /dev/null
+++ b/src/GameLogic/Bots/BotMiniGameHandler.cs
@@ -0,0 +1,177 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.MiniGames;
+using MUnique.OpenMU.GameLogic.Offline;
+using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames;
+
+///
+/// Lets server-side bots take part in the mini game events (Blood Castle, Devil Square, Chaos
+/// Castle) - but only ever in the wake of a human: when a real player who leads a party with bots
+/// enters an event with their own ticket, the party's bots follow them in. Bots never enter on
+/// their own, and they don't need tickets - the leader's entry is what legitimizes the visit.
+/// Each bot is checked against the same entry rules a player faces (the event level bracket,
+/// the master class requirement, the player killer restriction); a bot which does not qualify
+/// says goodbye and leaves the party to go back to its own hunting life, like a player who cannot
+/// join the run. Inside, the bot's routine switches to the event mode of the
+/// ; death and the event's end need no special handling, because the
+/// engine respawns a dead bot at the map's safezone (exactly like a player, which removes it from
+/// the event) and the event itself warps the remaining participants out when it ends.
+///
+internal static class BotMiniGameHandler
+{
+ ///
+ /// Takes the snapshot of the bots which would follow the given player into a mini game.
+ /// Must be called BEFORE the player actually enters: entering an event which disallows
+ /// parties (Chaos Castle) kicks the entering player out of its party, and with it the
+ /// knowledge of who was going to follow.
+ ///
+ /// The player about to enter a mini game.
+ /// The party bots to bring along; empty when the player is a bot itself, has no party or is not its master.
+ internal static IReadOnlyList SnapshotPartyBots(Player player)
+ {
+ if (player is OfflinePlayer
+ || player.Party is not { } party
+ || !ReferenceEquals(party.PartyMaster, player))
+ {
+ return [];
+ }
+
+ return party.PartyList
+ .OfType()
+ .Where(bot => bot.Account?.IsBot == true)
+ .ToList();
+ }
+
+ ///
+ /// Brings the party bots of a player who just successfully entered a mini game along into it.
+ /// Each bot's entry is queued into its own MuHelper tick (see ),
+ /// because warping and effect-clearing mutate the bot's state.
+ ///
+ /// The party leader who entered the mini game.
+ /// The snapshot taken by before the entry.
+ /// The definition of the entered mini game.
+ /// The mini game instance the leader entered.
+ internal static void BringPartyBotsAlong(Player leader, IReadOnlyList bots, MiniGameDefinition definition, MiniGameContext miniGame)
+ {
+ foreach (var bot in bots)
+ {
+ bot.PendingBotActions.Enqueue(() => TryEnterAsync(bot, leader, definition, miniGame));
+ }
+ }
+
+ ///
+ /// Determines whether the bot passes the same entry restrictions EnterMiniGameAction
+ /// checks for a player: the event's character level bracket, the master class requirement and
+ /// the player killer restriction. Pure decision logic - exposed for unit tests.
+ ///
+ /// The bot which wants to follow its leader in.
+ /// The mini game definition.
+ /// The human-readable reason when the bot does not qualify.
+ internal static bool IsEligible(Player bot, MiniGameDefinition definition, out string reason)
+ {
+ var level = (int)(bot.Attributes?[Stats.Level] ?? 0);
+
+ // The special characters (Magic Gladiator, Dark Lord, Rage Fighter, Summoner) enter the events in
+ // their own level bracket - the same distinction EnterMiniGameAction makes for a player. Judging
+ // them by the regular bracket kicked a qualified Magic Gladiator out of its leader's party as
+ // "below the minimum" (and would have let it in past its own maximum).
+ var isSpecialCharacter = bot.SelectedCharacter?.IsSpecialCharacter() == true;
+ var minimumLevel = isSpecialCharacter ? definition.MinimumSpecialCharacterLevel : definition.MinimumCharacterLevel;
+ var maximumLevel = isSpecialCharacter ? definition.MaximumSpecialCharacterLevel : definition.MaximumCharacterLevel;
+
+ if (level < minimumLevel)
+ {
+ reason = $"level {level} is below the minimum of {minimumLevel}";
+ return false;
+ }
+
+ if (level > maximumLevel)
+ {
+ reason = $"level {level} is above the maximum of {maximumLevel}";
+ return false;
+ }
+
+ if (definition.RequiresMasterClass && bot.SelectedCharacter?.CharacterClass?.IsMasterClass is not true)
+ {
+ reason = "it has not evolved into a master class yet";
+ return false;
+ }
+
+ if (!definition.ArePlayerKillersAllowedToEnter && bot.SelectedCharacter?.State >= HeroState.PlayerKiller1stStage)
+ {
+ reason = "player killers cannot enter";
+ return false;
+ }
+
+ reason = string.Empty;
+ return true;
+ }
+
+ private static async ValueTask TryEnterAsync(OfflinePlayer bot, Player leader, MiniGameDefinition definition, MiniGameContext miniGame)
+ {
+ if (bot.PlayerState.CurrentState != PlayerState.EnteredWorld
+ || !bot.IsAlive
+ || bot.CurrentMiniGame is not null)
+ {
+ return;
+ }
+
+ if (!IsEligible(bot, definition, out var reason))
+ {
+ // Like a player who cannot join the run: the bot says goodbye and goes back to its
+ // own hunting life instead of waiting at the gate.
+ bot.Logger.LogInformation(
+ "Bot '{Name}' cannot follow '{Leader}' into {Event} ({Reason}) and leaves the party.",
+ bot.Name,
+ leader.Name,
+ definition.Name,
+ reason);
+ if (bot.Party is { } party)
+ {
+ await party.KickMySelfAsync(bot).ConfigureAwait(false);
+ }
+
+ return;
+ }
+
+ if (definition.Entrance is not { } entrance)
+ {
+ return;
+ }
+
+ var enterResult = await miniGame.TryEnterAsync(bot).ConfigureAwait(false);
+ if (enterResult != EnterResult.Success)
+ {
+ // Full or already closed - not the bot's fault; it stays in the party and waits for
+ // the leader outside, hunting normally.
+ bot.Logger.LogInformation(
+ "Bot '{Name}' could not follow '{Leader}' into {Event}: {Result}.",
+ bot.Name,
+ leader.Name,
+ definition.Name,
+ enterResult);
+ return;
+ }
+
+ // Mirror of the player entry flow in EnterMiniGameAction, without ticket and entrance fee.
+ if (!definition.AllowParty && bot.Party is { } noPartyEventParty)
+ {
+ await noPartyEventParty.KickMySelfAsync(bot).ConfigureAwait(false);
+ }
+
+ await bot.MagicEffectList.ClearEffectsAfterDeathAsync().ConfigureAwait(false);
+ await bot.RemoveSummonAsync().ConfigureAwait(false);
+ await bot.WarpToAsync(entrance).ConfigureAwait(false);
+ bot.Logger.LogInformation(
+ "Bot '{Name}' follows '{Leader}' into {Event}.",
+ bot.Name,
+ leader.Name,
+ definition.Name);
+ }
+}
diff --git a/src/GameLogic/Bots/BotMuHelperSettings.cs b/src/GameLogic/Bots/BotMuHelperSettings.cs
new file mode 100644
index 000000000..06fa4f851
--- /dev/null
+++ b/src/GameLogic/Bots/BotMuHelperSettings.cs
@@ -0,0 +1,212 @@
+//
+// 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.MuHelper;
+
+///
+/// Default MU Helper settings used to drive a bot's combat AI.
+/// A bot never sends a client-side MU Helper configuration, so without this the player would
+/// fall back to a hunting range of a single tile (see ).
+/// These defaults make the bot hunt nearby monsters, pick up the valuable drops and use
+/// potions, while staying close to its spawn origin.
+///
+internal sealed class BotMuHelperSettings : IMuHelperSettings
+{
+ ///
+ public int BasicSkillId => 0;
+
+ ///
+ public int ActivationSkill1Id => 0;
+
+ ///
+ public int ActivationSkill2Id => 0;
+
+ ///
+ public int DelayMinSkill1 => 0;
+
+ ///
+ public int DelayMinSkill2 => 0;
+
+ ///
+ public bool Skill1UseTimer => false;
+
+ ///
+ public bool Skill1UseCondition => false;
+
+ ///
+ public bool Skill1ConditionAttacking => false;
+
+ ///
+ public int Skill1SubCondition => 0;
+
+ ///
+ public bool Skill2UseTimer => false;
+
+ ///
+ public bool Skill2UseCondition => false;
+
+ ///
+ public bool Skill2ConditionAttacking => false;
+
+ ///
+ public int Skill2SubCondition => 0;
+
+ ///
+ public bool UseCombo => false;
+
+ ///
+ public int HuntingRange => 6;
+
+ ///
+ public int MaxSecondsAway => 30;
+
+ ///
+ public bool LongRangeCounterAttack => false;
+
+ ///
+ ///
+ /// Disabled for bots: the is the sole driver of travel between hunting
+ /// grounds, so the offline movement handler must not try to walk the bot back to its origin in parallel.
+ ///
+ public bool ReturnToOriginalPosition => false;
+
+ ///
+ public int BuffSkill0Id => 0;
+
+ ///
+ public int BuffSkill1Id => 0;
+
+ ///
+ public int BuffSkill2Id => 0;
+
+ ///
+ public bool BuffOnDuration => false;
+
+ ///
+ public bool BuffDurationForParty => false;
+
+ ///
+ public int BuffCastIntervalSeconds => 0;
+
+ // With the class heal skill learned (e.g. elf Heal), the HealingHandler casts it below the threshold
+ // before falling back to potions - the same order a real player follows.
+
+ ///
+ public bool AutoHeal => true;
+
+ ///
+ public int HealThresholdPercent => 60;
+
+ ///
+ public bool UseDrainLife => false;
+
+ ///
+ public bool UseHealPotion => true;
+
+ ///
+ public int PotionThresholdPercent => 60;
+
+ ///
+ // Bots hunt in small parties (see BotManager.FormParties): the elf heals the group, buffs are
+ // shared, and the party experience bonus applies - like a real group of players.
+ public bool SupportParty => true;
+
+ ///
+ public bool AutoHealParty => true;
+
+ ///
+ public int HealPartyThresholdPercent => 60;
+
+ ///
+ public bool UseDarkRaven => false;
+
+ ///
+ public int DarkRavenMode => 0;
+
+ ///
+ public int ObtainRange => 6;
+
+ ///
+ public bool PickAllItems => false;
+
+ ///
+ // Must be true: the pickup handler bails out early unless PickAllItems or PickSelectItems is set,
+ // so with this off the selective PickZen/PickJewel/PickAncient flags below never take effect.
+ public bool PickSelectItems => true;
+
+ ///
+ public bool PickJewel => true;
+
+ ///
+ public bool PickZen => true;
+
+ ///
+ public bool PickAncient => true;
+
+ ///
+ public bool PickExcellent => true;
+
+ ///
+ public bool PickExtraItems => false;
+
+ ///
+ public IReadOnlyList ExtraItemNames => Array.Empty();
+
+ ///
+ ///
+ /// Disabled on purpose: offline auto-repair has no NPC discount and drains Zen at an
+ /// increased rate. Bots should not burn their balance on repairs during the proof of concept.
+ ///
+ public bool RepairItem => false;
+
+ ///
+ /// Bots fight back when a player attacks them (see ).
+ public bool UseSelfDefense => true;
+
+ ///
+ public bool AutoAcceptFriend => false;
+
+ ///
+ public bool AutoAcceptGuild => false;
+
+ ///
+ ///
+ /// Bots accept party invitations from any player, like a friendly stranger would - within the
+ /// safeguards applied by (level gap, not while busy, limited time).
+ ///
+ public bool AutoAcceptAnyone => true;
+
+ ///
+ public bool FallbackBasicAttack => true;
+
+ ///
+ ///
+ /// Enabled for bots: they have no client-side skill configuration, so the combat AI auto-selects the
+ /// strongest learned attack skill the character can currently afford. Combined with the level-gated
+ /// skills granted at generation (see ), this makes bots cast class- and
+ /// level-appropriate magic/skills instead of only swinging their weapon.
+ ///
+ public bool AutoSelectBestSkill => true;
+
+ ///
+ /// Bots keep their class's learned buffs up automatically (e.g. elf Greater Defense/Greater Damage).
+ public bool AutoSelectBuffs => true;
+
+ ///
+ /// Casters drink mana potions, so they keep casting instead of degrading to weak melee.
+ public bool UseManaPotion => true;
+
+ ///
+ ///
+ /// Bots only engage monsters they can handle (the navigator's safe-monster cap). Without this, a bot
+ /// travelling through hostile territory picks fights with monsters far above its level and dies.
+ ///
+ public bool OnlyHuntSafeMonsters => true;
+
+ ///
+ /// Bots evaluate dropped gear and pick up upgrades for their own class (see ).
+ public bool PickUpgradeItems => true;
+}
diff --git a/src/GameLogic/Bots/BotNameGenerator.cs b/src/GameLogic/Bots/BotNameGenerator.cs
new file mode 100644
index 000000000..f2bb9621a
--- /dev/null
+++ b/src/GameLogic/Bots/BotNameGenerator.cs
@@ -0,0 +1,92 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using System.Globalization;
+using System.Threading;
+using MUnique.OpenMU.Persistence;
+
+///
+/// Generates pronounceable, realistic-looking character names for bots.
+///
+///
+/// Names are built procedurally from syllables so the generator scales to thousands of unique
+/// names while still looking like names a real player might pick. Every generated name satisfies
+/// the default character name rules (3-10 alphanumeric characters), so bots blend in with players;
+/// the bot nature is tracked by , never by the name.
+///
+internal sealed class BotNameGenerator
+{
+ private static readonly string[] Starts =
+ {
+ "Dra", "Kar", "Mil", "Tho", "Zan", "Bel", "Gor", "Vyn", "Ael", "Mor",
+ "Run", "Syl", "Tor", "Kra", "Fen", "Lyr", "Nyx", "Ori", "Var", "Eld",
+ "Bro", "Cyn", "Dar", "Hal", "Ith", "Jor", "Kae", "Lor", "Mag", "Nor",
+ "Pyr", "Rha", "Ser", "Ulr", "Wyn", "Xan", "Yor", "Zel", "Ari", "Cae",
+ };
+
+ private static readonly string[] Middles =
+ {
+ string.Empty, string.Empty, string.Empty,
+ "a", "e", "i", "o", "ia", "ae", "an", "or", "el", "yn", "ar",
+ };
+
+ private static readonly string[] Ends =
+ {
+ "dor", "lin", "rik", "gar", "wyn", "ric", "mir", "dan", "eth", "ron",
+ "ana", "ella", "ix", "ael", "oth", "ara", "une", "is", "ius", "wen",
+ };
+
+ private readonly IRandomizer _randomizer = Rand.GetRandomizer();
+
+ ///
+ /// Generates a name which is not yet used, neither within this run nor in the database.
+ ///
+ /// The context used to check name availability.
+ /// The set of names already handed out in this run; the returned name is added to it.
+ /// The cancellation token.
+ /// A unique, valid character name.
+ public async ValueTask GenerateUniqueAsync(IPlayerContext context, ISet reserved, CancellationToken cancellationToken = default)
+ {
+ for (var attempt = 0; attempt < 500; attempt++)
+ {
+ var name = this.BuildName(attempt);
+ if (!reserved.Add(name))
+ {
+ continue;
+ }
+
+ var existing = await context.GetAccountByCharacterNameAsync(name, cancellationToken).ConfigureAwait(false);
+ if (existing is null)
+ {
+ return name;
+ }
+ }
+
+ throw new InvalidOperationException("Could not generate a unique bot character name after many attempts.");
+ }
+
+ private string BuildName(int attempt)
+ {
+ var start = Starts.SelectRandom(this._randomizer)!;
+ var middle = Middles.SelectRandom(this._randomizer)!;
+ var end = Ends.SelectRandom(this._randomizer)!;
+ var name = start + middle + end;
+
+ // Once the simple name space gets crowded, append a digit to keep finding free names
+ // without ever exceeding the 10 character limit.
+ if (attempt > 30)
+ {
+ var suffix = (attempt % 10).ToString(CultureInfo.InvariantCulture);
+ name = (name.Length >= 10 ? name[..9] : name) + suffix;
+ }
+ else if (name.Length > 10)
+ {
+ name = name[..10];
+ }
+
+ return char.ToUpperInvariant(name[0]) + name[1..].ToLowerInvariant();
+ }
+}
diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs
new file mode 100644
index 000000000..b640fa32d
--- /dev/null
+++ b/src/GameLogic/Bots/BotNavigator.cs
@@ -0,0 +1,1626 @@
+//
+// 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.Diagnostics.CodeAnalysis;
+using System.Threading;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.NPC;
+using MUnique.OpenMU.GameLogic.Offline;
+using MUnique.OpenMU.GameLogic.PlayerActions;
+using MUnique.OpenMU.Pathfinding;
+using MUnique.OpenMU.Persistence;
+
+///
+/// Drives a bot's high-level navigation. It picks a hunting ground that suits the bot's level from
+/// the monster spawn areas of the current map and points the bot's
+/// at it. The existing offline movement handler then walks the bot there (using the server pathfinder)
+/// and the combat handler hunts locally - so a bot which spawns in town finds its own way to the monsters.
+///
+internal sealed class BotNavigator : AsyncDisposable
+{
+ ///
+ /// A bot only warps to another map if it offers safe monsters at least this many levels stronger.
+ /// Sized as a hysteresis band: the safety verdict for a borderline monster flips when the bot's
+ /// defense changes with its buffs, and a small margin let two maps leapfrog each other on every
+ /// re-evaluation - the bot ping-ponged between them on warp cooldown.
+ ///
+ private const int WarpImprovementMargin = 8;
+
+ ///
+ /// Below this level a bot never warps: it stays on its class starting map (e.g. elves in Noria,
+ /// summoners in Elvenland), so the newbie maps stay populated instead of everyone drifting to one map.
+ ///
+ private const int MinWarpLevel = 30;
+
+ /// Width of the level band of areas we randomize between, so bots don't all stack on one spot.
+ private const int BandWidth = 3;
+
+ /// Range (tiles) around the origin that counts as "at the hunting ground".
+ private const int HuntingRange = 6;
+
+ ///
+ /// How far the bot looks for an actual live monster to home in on. MU spawn areas span most of the map
+ /// (e.g. Lorencia's are ~86x233 / ~105x68 tiles) with only a few dozen monsters each, so their monsters
+ /// sit ~20 tiles apart. Walking to a random tile inside such an area almost never lands within the 6-tile
+ /// combat range of a real monster - the bot arrives, finds nothing to fight, and wanders off. So once the
+ /// bot is anywhere in the region it scans this radius for the nearest live monster and heads straight for
+ /// it. Kept modest so the per-tick area-of-interest scan stays cheap even with hundreds of bots.
+ ///
+ private const int MonsterSeekRadius = 40;
+
+ /// Item group of potions (healing and mana potions share group 14).
+ private const int HealPotionGroup = 14;
+
+ /// Item number of the Large Healing Potion within .
+ private const int HealPotionNumber = 3;
+
+ /// Item number of the Large Mana Potion within .
+ private const int ManaPotionNumber = 6;
+
+ ///
+ /// How many charges an emergency refill stocks up (the potion item's Durability holds the remaining
+ /// count; one is spent per heal). Spread over as many item stacks as the definition's stack size
+ /// needs: a Large Healing Potion holds three charges per stack, so a bot handed a single 255-charge
+ /// "potion" carried an item the game cannot produce - its stack never looked low again (the shopping
+ /// loop for that potion kind went dead), and stacking it with a bought one underflowed the count.
+ ///
+ private const int EmergencyPotionCharges = 21;
+
+ ///
+ /// Emergency refill threshold: normally the bot restocks potions by BUYING them at a merchant (see
+ /// , triggered well above this level); only when a stack still runs
+ /// this low (e.g. no Zen, merchant unreachable) is it topped up out of thin air as a last resort,
+ /// so the bot never dies over an empty bottle.
+ ///
+ private const int HealPotionRefillBelow = 10;
+
+ /// Number of path steps to issue per travel hop. Short hops let the bot stop and fight between hops.
+ private const int StepsPerHop = 3;
+
+ ///
+ /// How far the travel destination may drift (e.g. a roaming monster the bot homes in on) before the
+ /// cached route is re-planned. Re-planning a whole-map A* every tick just to consume three steps
+ /// starved the path finder pool once dozens of bots travelled at the same time.
+ ///
+ private const int PathReuseTolerance = 4;
+
+ /// How close (tiles) the bot must be to the merchant to trade.
+ private const int MerchantTalkRange = 3;
+
+ ///
+ /// If a monster is within this range the bot stops travelling and lets the combat handler engage.
+ /// MUST equal the combat hunting range: the combat handler only ever targets monsters within
+ /// of the bot, so stopping for anything further away would
+ /// hand control to the combat handler for a monster it never fights - the bot would freeze in place
+ /// (stop travelling, but find nothing to attack) until the monster happened to wander off.
+ ///
+ private const int TravelStopRange = HuntingRange;
+
+ /// Search limit for the travel path finder, generous enough for a full cross-map route.
+ private const int TravelSearchLimit = 20000;
+
+ /// Heuristic weight while travelling: high enough to make the A* search greedy and cheap over long routes.
+ private const int TravelHeuristicWeight = 12;
+
+ private const int MaxPointPickAttempts = 25;
+
+ /// A party member keeps within this distance (tiles) of its leader; beyond it, it walks back.
+ private const int FollowDistance = 10;
+
+ ///
+ /// Hunting grounds closer than this (tiles) to a recent death site are avoided after the same
+ /// player killed the bot repeatedly (see ),
+ /// so the bot farms somewhere else instead of walking back into the same lost fight.
+ ///
+ private const int DeathSiteAvoidanceRange = 30;
+
+ private static readonly TimeSpan EvaluationInterval = TimeSpan.FromSeconds(1);
+ private static readonly TimeSpan EmptyGroundGrace = TimeSpan.FromSeconds(8);
+
+ /// How often the bot checks its backpack for equippable upgrades.
+ private static readonly TimeSpan EquipCheckInterval = TimeSpan.FromSeconds(8);
+
+ /// How often the bot considers whether it needs a shopping trip.
+ private static readonly TimeSpan ShoppingCheckInterval = TimeSpan.FromSeconds(30);
+
+ /// Cooldown after a shopping trip before considering the next one.
+ private static readonly TimeSpan ShoppingCooldown = TimeSpan.FromMinutes(10);
+
+ /// Minimum time between two cross-map warps, so a bot does not bounce between maps (a real player does not hop maps every minute either).
+ private static readonly TimeSpan WarpCooldown = TimeSpan.FromMinutes(3);
+
+ /// Cooldown for following the party leader to another map (shorter, so the group regroups quickly).
+ private static readonly TimeSpan FollowWarpCooldown = TimeSpan.FromSeconds(20);
+
+ ///
+ /// How long the party leader has to stay on a map before its bots follow him there. A leader who is
+ /// only passing through (a town trip for supplies, a gate on the way somewhere) must not drag his
+ /// whole party across the world and back - real party members wait to see where he settles, too.
+ ///
+ private static readonly TimeSpan LeaderSettleTime = TimeSpan.FromSeconds(20);
+
+ ///
+ /// Bounds of the random delay between becoming eligible for a character reset and performing it.
+ /// A real player finishes the fight, walks to town, maybe trades first - and 250 bots resetting
+ /// the very second they hit the required level would be an obvious give-away (and a thundering
+ /// herd of simultaneous re-gearing). The delay is rolled per bot when it becomes eligible.
+ ///
+ private static readonly TimeSpan MinResetDelay = TimeSpan.FromMinutes(1);
+
+ /// Upper bound of the random reset delay, see .
+ private static readonly TimeSpan MaxResetDelay = TimeSpan.FromMinutes(10);
+
+ ///
+ /// If the bot's position has not changed for this long it is considered stuck (a wedged walk, or a
+ /// monster it can't reach). The navigator then forces a fresh destination so the bot gets going again.
+ ///
+ private static readonly TimeSpan StuckTimeout = TimeSpan.FromSeconds(8);
+
+ ///
+ /// Stuck timeout when huntable monsters are nearby: much longer, because a bot standing still while
+ /// monsters are around is usually just fighting them from one tile. It still eventually fires, which
+ /// breaks the rare deadlock of a monster that is in range but cannot be attacked or reached.
+ ///
+ private static readonly TimeSpan StuckWithMonstersTimeout = TimeSpan.FromSeconds(30);
+
+ ///
+ /// A pool of full-map path finders for long-distance travel. The pooled (scoped) path finder rejects
+ /// start/end further apart than ~16 tiles, so travel uses a which resolves
+ /// routes across the whole map. A path finder is not safe for concurrent use, so we keep a small pool and
+ /// hand one out per call: several bots can plan routes in parallel (a single shared instance serialized
+ /// every bot and starved navigation once the population reached the hundreds), while the pool size caps
+ /// how many CPU cores whole-map searches may occupy at once.
+ ///
+ private static readonly int TravelPathFinderPoolSize = Math.Clamp(Environment.ProcessorCount / 3, 2, 6);
+
+ private static readonly SemaphoreSlim TravelPathFinderPool = new(TravelPathFinderPoolSize, TravelPathFinderPoolSize);
+
+ private static readonly ConcurrentBag TravelPathFinders = CreateTravelPathFinderPool();
+
+ private readonly OfflinePlayer _player;
+ private readonly CancellationTokenSource _cts = new();
+
+ private Timer? _timer;
+ private DateTime? _emptyGroundSince;
+ private int _isEvaluating;
+ private Point _destination;
+ private bool _hasDestination;
+ private DateTime _lastWarpUtc = DateTime.MinValue;
+ private Point _lastPosition;
+ private DateTime _lastMoveUtc = DateTime.MinValue;
+ private DateTime _nextEquipCheckUtc = DateTime.MinValue;
+ private IList? _travelPath;
+ private int _travelPathIndex;
+ private Point _travelPathTarget;
+ private Point? _shoppingTarget;
+ private DateTime _nextShoppingCheckUtc = DateTime.MinValue;
+ private DateTime? _resetDueAtUtc;
+ private short _leaderMapNumber;
+ private DateTime _leaderOnMapSinceUtc = DateTime.MinValue;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The bot player.
+ public BotNavigator(OfflinePlayer player)
+ {
+ this._player = player;
+ }
+
+ ///
+ /// Starts the periodic navigation evaluation. The start delay is jittered per bot, so hundreds of
+ /// navigators do not all fire on the same second boundary (smoother load, less robotic synchrony).
+ ///
+ public void Start()
+ {
+ // The token is captured ONCE, not read from the (later disposed) source on every shot: a timer
+ // callback which is already running when Dispose gets rid of the source would throw an
+ // ObjectDisposedException right in the callback - unhandled, on a thread pool thread, taking the
+ // whole server process down with it. Same pattern as the MU Helper tick.
+ var cancellationToken = this._cts.Token;
+ this._timer ??= new Timer(
+ state => _ = this.SafeEvaluateAsync(cancellationToken),
+ null,
+ TimeSpan.FromMilliseconds(2000 + Rand.NextInt(0, 1500)),
+ EvaluationInterval);
+ }
+
+ ///
+ protected override async ValueTask DisposeAsyncCore()
+ {
+ await this._cts.CancelAsync().ConfigureAwait(false);
+ await base.DisposeAsyncCore().ConfigureAwait(false);
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ this._timer?.Dispose();
+ this._timer = null;
+ this._cts.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ private static PathFinder CreateTravelPathFinder()
+ {
+ return new PathFinder(new FullGridNetwork(true))
+ {
+ Heuristic = new ManhattanTravelHeuristic(),
+ HeuristicEstimate = TravelHeuristicWeight,
+ SearchLimit = TravelSearchLimit,
+ };
+ }
+
+ private static ConcurrentBag CreateTravelPathFinderPool()
+ {
+ var pool = new ConcurrentBag();
+ for (var i = 0; i < TravelPathFinderPoolSize; i++)
+ {
+ pool.Add(CreateTravelPathFinder());
+ }
+
+ return pool;
+ }
+
+ private static int GetMonsterLevel(MonsterDefinition definition)
+ => (int)(definition.Attributes.FirstOrDefault(a => a.AttributeDefinition == Stats.Level)?.Value ?? 0f);
+
+ private static int GroundWeight(MonsterSpawnArea area, Point from)
+ {
+ var proximity = Math.Max(1, 300 - GroundDistance(area, from));
+ return Math.Max(1, (int)area.Quantity) * proximity;
+ }
+
+ /// Manhattan distance between the center of the spawn area and the given point.
+ private static int GroundDistance(MonsterSpawnArea area, Point from)
+ {
+ var centerX = (area.X1 + area.X2) / 2;
+ var centerY = (area.Y1 + area.Y2) / 2;
+ return Math.Abs(from.X - centerX) + Math.Abs(from.Y - centerY);
+ }
+
+ private async Task SafeEvaluateAsync(CancellationToken cancellationToken)
+ {
+ // The timer fires on a fixed interval regardless of whether the previous evaluation finished;
+ // skip overlapping runs so a single bot never has two travel walks / searches in flight.
+ if (Interlocked.CompareExchange(ref this._isEvaluating, 1, 0) != 0)
+ {
+ return;
+ }
+
+ try
+ {
+ await this.EvaluateAsync(cancellationToken).ConfigureAwait(false);
+ this._player.OnAiTickSucceeded();
+ }
+ catch (OperationCanceledException)
+ {
+ // expected during shutdown
+ }
+ catch (Exception ex)
+ {
+ this._player.Logger.LogError(ex, "Bot navigator error for {Account}.", this._player.AccountLoginName);
+ this._player.OnAiTickFailed();
+ }
+ finally
+ {
+ this._isEvaluating = 0;
+ }
+ }
+
+ private async ValueTask EvaluateAsync(CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested
+ || this._player.PlayerState.CurrentState != PlayerState.EnteredWorld
+ || !this._player.IsAlive
+ || this._player.CurrentMap is not { } map)
+ {
+ return;
+ }
+
+ // Inside a mini game event the whole normal routine below is suspended - the event
+ // dictates the pace, and every branch which could move the bot off the event map
+ // (shopping, revenge, warping, ground picking) must not run.
+ if (this._player.CurrentMiniGame is not null)
+ {
+ await this.EvaluateInsideMiniGameAsync(map, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ // Party bookkeeping: answer a pending invitation from a player once its human-like delay
+ // passed, and leave a party with a human again when the bot got bored (see BotPartyHandler).
+ await BotPartyHandler.ProcessAsync(this._player).ConfigureAwait(false);
+
+ // Make sure the bot always carries healing and mana potions. Without them the offline handlers
+ // have nothing to drink: the bot dies to sustained damage, and casters degrade to weak melee
+ // once their mana runs dry. This also tops up already-generated bots at runtime.
+ // Queued into the MuHelper tick: an ammunition refill lands in an equipped hand slot, which
+ // mounts item power-ups into the attribute system - and any attribute mutation from this
+ // (separate) timer racing the combat handler's recalculations can corrupt the attribute
+ // graph permanently (see PendingBotActions).
+ this._player.PendingBotActions.Enqueue(() => this.EnsurePotionsAsync());
+
+ // Periodically evaluate looted gear and equip upgrades (drops the replaced piece), so the bot's
+ // equipment progresses over its lifetime like a real player's. Queued for the same reason:
+ // equipping mounts/unmounts item power-ups.
+ if (DateTime.UtcNow >= this._nextEquipCheckUtc)
+ {
+ this._nextEquipCheckUtc = DateTime.UtcNow + EquipCheckInterval;
+ this._player.PendingBotActions.Enqueue(() => BotEquipmentHandler.TryEquipUpgradesAsync(this._player));
+
+ // Wings don't drop, so the loot-driven equipment progression above never provides them;
+ // they are earned at the classic level milestones instead (see BotWingHandler). Queued
+ // for the same reason: equipping mounts item power-ups.
+ this._player.PendingBotActions.Enqueue(() => BotWingHandler.TryAdvanceWingsAsync(this._player));
+ }
+
+ // Keep the combat centre on the bot's current position, so the combat handler always engages
+ // monsters right next to the bot - both at the hunting ground and while travelling through hostile
+ // territory (self-defence). This is what keeps a travelling bot alive. The travel destination is
+ // tracked separately in _destination.
+ var inSafezone = map.Terrain.SafezoneMap[this._player.Position.X, this._player.Position.Y];
+
+ // Combat centre: normally the bot's own position (self-defence while travelling and at the hunting
+ // ground). But while inside the safezone we aim it at the destination, so the combat handler does NOT
+ // make the bot stand in town swinging at monsters just outside (which it can't damage from a safezone) -
+ // it should simply walk out of town instead.
+ this._player.HuntingOrigin = inSafezone && this._hasDestination ? this._destination : this._player.Position;
+
+ // Watchdog bookkeeping: remember when the bot last actually changed position.
+ if (this._player.Position != this._lastPosition)
+ {
+ this._lastPosition = this._player.Position;
+ this._lastMoveUtc = DateTime.UtcNow;
+ }
+
+ // Only monsters the bot would actually fight count (the combat AI ignores ones failing its
+ // damage-based safety check), so a too-strong monster next to the bot neither stops its travel
+ // nor suppresses the stuck watchdog.
+ var monstersNearby = map.GetAttackablesInRange(this._player.Position, TravelStopRange)
+ .OfType()
+ .Count(m => m.IsAlive && !m.IsAtSafezone() && CombatHandler.IsSafeTarget(this._player, m.Definition));
+
+ // The bot counts as stuck when it has been frozen on the spot: quickly when there is nothing to
+ // fight (a wedged walk or blocked path), and after a much longer grace when huntable monsters are
+ // around - standing still next to monsters usually just means fighting, but if the position stays
+ // frozen well beyond that, something is wedged (e.g. a monster in range that cannot be reached).
+ var stuckTimeout = monstersNearby > 0 ? StuckWithMonstersTimeout : StuckTimeout;
+ var stuck = DateTime.UtcNow - this._lastMoveUtc > stuckTimeout;
+ if (stuck)
+ {
+ // Break free: pick a fresh hunting ground and walk to it now. Issuing the walk resets the wedged
+ // walker, and a new destination avoids re-stalling on the same blocked path.
+ this._lastMoveUtc = DateTime.UtcNow;
+ this._emptyGroundSince = null;
+ await this.PickGroundAndTravelAsync(map, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ // A walk (travel hop or local combat move) is already in progress.
+ if (this._player.IsWalking)
+ {
+ 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
+ // cycle (see BotResetHandler).
+ if (await this.TryResetCharacterAsync().ConfigureAwait(false))
+ {
+ return;
+ }
+
+ // A master bot invests freshly earned master points (one per master level). Queued into the
+ // MuHelper tick like the level-up progression - learning a skill mutates the skill list, which
+ // must never happen while the combat handler enumerates it (see PendingBotActions). The master
+ // evolution itself is handled by the feature plugin's maintenance pass, because it restarts the bot.
+ if (BotMasterHandler.HasMasterPointsToSpend(this._player))
+ {
+ this._player.PendingBotActions.Enqueue(() => BotMasterHandler.TrySpendMasterPointsAsync(this._player));
+ }
+
+ // A shopping trip (selling junk loot, restocking potions with Zen) runs before everything else,
+ // so it completes even for party members - the follow logic below would otherwise pull them
+ // back to the leader mid-trade.
+ if (await this.TryShoppingAsync(map, inSafezone, cancellationToken).ConfigureAwait(false))
+ {
+ return;
+ }
+
+ // An armed revenge (a player killed this bot; it respawned on the same map) overrides the
+ // whole normal routine below, including following the party leader: the bot marches back to
+ // the place of its death. A leader on a revenge march still drags its followers along - the
+ // posse a wronged player would bring. Once the revenge is spent or times out, the bot falls
+ // back into the routine (and a follower rejoins its group).
+ if (await this.TryRevengeMarchAsync(map, cancellationToken).ConfigureAwait(false))
+ {
+ return;
+ }
+
+ // Party members follow their leader instead of roaming on their own: to the leader's map when
+ // it warped away, and towards the leader when it moved off. Near the leader they hunt normally
+ // (the party heal/buff handlers and the party experience bonus need the group to stay together).
+ // Followers never warp on their own - the leader (the lowest-level member, so it only ever picks
+ // maps the whole group can hunt on) decides where the party goes.
+ var leaderToFollow = this.GetPartyLeaderToFollow();
+ if (leaderToFollow is not null)
+ {
+ if (await this.TryFollowLeaderAsync(map, leaderToFollow, cancellationToken).ConfigureAwait(false))
+ {
+ return;
+ }
+ }
+
+ // A bot whose map is no longer the best it could safely hunt warps away with priority - even
+ // though trivial monsters are still around. Without this, the nearest-monster homing always
+ // finds SOMETHING huntable on the starter map, the warp logic below is never reached, and
+ // high-level bots farm level-5 mobs on Lorencia forever (with the experience penalty) instead
+ // of moving on to their level's maps. TryWarpToBetterMapAsync itself only fires when another
+ // map is meaningfully better (margin + cooldown), so a bot on an adequate map stays put.
+ if (leaderToFollow is null
+ && await this.TryWarpToBetterMapAsync().ConfigureAwait(false))
+ {
+ return;
+ }
+
+ // Inside the safezone the bot can't fight anyway, so it must keep walking out of town instead of
+ // freezing at the gate when a monster is just outside. Only stop to fight once it has left the safezone.
+ if (!inSafezone && monstersNearby > 0)
+ {
+ this._emptyGroundSince = null;
+ return;
+ }
+
+ // Home in on a nearby live monster instead of walking to a random tile in a map-sized spawn area
+ // (where the bot would almost never arrive within combat range of an actual monster). This is what
+ // makes the bot converge on real monsters and actually fight. The bot walks toward the monster;
+ // once within the combat range the branch above takes over and the combat handler engages. This
+ // also runs inside the safezone, so a bot in town heads straight for the monsters just outside the
+ // gate instead of a random far-away spawn point. Only when nothing is loaded within the seek radius
+ // (deep in town, or all nearby monsters too strong) does it fall back to the coarse spawn-area /
+ // warp logic below to relocate.
+ if (this.TryFindNearestMonsterGround(map, out var monsterGround))
+ {
+ this._destination = monsterGround;
+ this._hasDestination = true;
+ if (await this.TravelTowardAsync(map, monsterGround, cancellationToken).ConfigureAwait(false))
+ {
+ this._emptyGroundSince = null;
+ return;
+ }
+
+ // Unreachable monster (e.g. across a river): drop it and fall through to pick a spawn-area ground
+ // or warp, instead of re-targeting the same unreachable monster every tick.
+ this._hasDestination = false;
+ }
+
+ // Nothing to fight here. If we still have a destination to reach, keep walking towards it. If the
+ // route turned out to be impossible (e.g. across a river), drop it and fall through to pick another
+ // ground in this same tick instead of standing still.
+ if (this._hasDestination && this._player.GetDistanceTo(this._destination) > HuntingRange)
+ {
+ if (!await this.TravelTowardAsync(map, this._destination, cancellationToken).ConfigureAwait(false))
+ {
+ // Impossible route: drop the destination and wait out the empty-ground grace before
+ // picking another one. Re-picking immediately turned a pocket of unreachable picks
+ // into a full-tick churn - a new far-away ground every second, hammering the path
+ // finder without the bot ever moving.
+ this._hasDestination = false;
+ this._emptyGroundSince ??= DateTime.UtcNow;
+ }
+
+ return;
+ }
+
+ // Arrived (or no destination) and the area is empty: wait out a short grace, then pick a new ground.
+ // In the safezone we skip the grace so a freshly-spawned bot heads out of town straight away.
+ this._hasDestination = false;
+ if (!inSafezone)
+ {
+ this._emptyGroundSince ??= DateTime.UtcNow;
+ if (DateTime.UtcNow - this._emptyGroundSince < EmptyGroundGrace)
+ {
+ return;
+ }
+ }
+
+ this._emptyGroundSince = null;
+
+ if (leaderToFollow is null && await this.TryWarpToBetterMapAsync().ConfigureAwait(false))
+ {
+ return;
+ }
+
+ await this.PickGroundAndTravelAsync(map, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Drives a shopping trip: when the backpack fills up or potions run low, the bot heads to a town
+ /// merchant (warping to the map's safezone first when out in the field, like using a town portal),
+ /// walks up to it and trades - selling junk loot and restocking potions with Zen.
+ ///
+ /// True, if the shopping trip consumed this tick.
+ private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone, CancellationToken cancellationToken)
+ {
+ if (this._shoppingTarget is { } target)
+ {
+ if (this._player.GetDistanceTo(target) > MerchantTalkRange)
+ {
+ if (!await this.TravelTowardAsync(map, target, cancellationToken).ConfigureAwait(false))
+ {
+ // No way to the merchant from here - give up this trip and don't retry every
+ // check interval (an unreachable merchant stays unreachable for a while; a bot
+ // retrying twice a minute wastes its hunting time on futile marches).
+ this._player.Logger.LogInformation("Bot '{Name}' gives up its shopping trip: no route to the merchant at {Target}.", this._player.Name, target);
+ this._shoppingTarget = null;
+ this._player.IsOnShoppingTrip = false;
+ this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCooldown;
+ }
+
+ return true;
+ }
+
+ // The trade itself runs in the MU Helper tick, not here: selling and buying mutate the
+ // inventory and the Zen of a character whose combat tick may be running at this very moment
+ // (the open NPC dialog only holds off the NEXT tick) - and both write through the same,
+ // not-thread-safe persistence context. Same rule as for every other bot-initiated mutation,
+ // see PendingBotActions. The jewels are spent right after the trade, in the same tick.
+ this._player.PendingBotActions.Enqueue(() => this.TradeAndUpgradeAsync(map, target));
+
+ this._shoppingTarget = null;
+ this._player.IsOnShoppingTrip = false;
+ this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCooldown;
+ this._lastMoveUtc = DateTime.UtcNow; // standing at the shop is not "stuck"
+ return true;
+ }
+
+ if (DateTime.UtcNow < this._nextShoppingCheckUtc)
+ {
+ return false;
+ }
+
+ this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCheckInterval;
+ if (!BotShoppingHandler.NeedsShopping(this._player))
+ {
+ return false;
+ }
+
+ if (BotShoppingHandler.FindMerchantPosition(map) is not { } merchantPosition)
+ {
+ // No merchant lives on this map at all (the Dungeon has no town). Shopping here would
+ // starve the bot's logistics for as long as it stays - no potion restock, no selling, a
+ // slowly silting backpack. Like a player pulling a town scroll, it warps to its class
+ // home town and shops there; the better-map logic takes it back hunting afterwards.
+ if (this.TryGetHomeEscapeGate(out var homeGate, out var homeMap, out _))
+ {
+ this._travelPath = null;
+ this._hasDestination = false;
+ this._lastWarpUtc = DateTime.UtcNow;
+ this._nextShoppingCheckUtc = DateTime.UtcNow; // start the trip on the new map right away
+ this._player.Logger.LogInformation("Bot '{Name}' warps home to {Map} for a shopping trip - no merchant on its map.", this._player.Name, homeMap.Name);
+ await this._player.WarpToAsync(homeGate).ConfigureAwait(false);
+ return true;
+ }
+
+ return false;
+ }
+
+ // Merchants stand in town: when out in the field, warp to the map's safezone first (like using
+ // a town portal scroll), then walk over to the merchant.
+ if (!inSafezone
+ && map.Definition.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is { } townGate)
+ {
+ this._travelPath = null;
+ this._hasDestination = false;
+ await this._player.WarpToAsync(townGate).ConfigureAwait(false);
+ }
+
+ this._shoppingTarget = merchantPosition;
+ this._player.IsOnShoppingTrip = true;
+ this._player.Logger.LogInformation("Bot '{Name}' heads to the merchant for a shopping trip.", this._player.Name);
+ return true;
+ }
+
+ ///
+ /// The trade at the merchant, as it runs inside the MU Helper tick: sell the junk, restock the
+ /// potions and - still standing safely at the shop with the dialog closed - spend a few of the
+ /// looted jewels on the own gear, the player-like moment for it (see ).
+ ///
+ private async ValueTask TradeAndUpgradeAsync(GameMap map, Point merchantPosition)
+ {
+ if (await BotShoppingHandler.TryTradeAsync(this._player, map, merchantPosition).ConfigureAwait(false))
+ {
+ await BotJewelHandler.TryUpgradeGearAsync(this._player).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Marches the bot back to the place of its death while a revenge is armed (see
+ /// ). The single attempt is spent as soon as
+ /// the bot arrives or runs into its killer on the way - no extra combat logic is needed here,
+ /// because the re-armed aggressor memory already keeps the combat handler prioritizing the
+ /// killer, struck only when legal (see ).
+ /// Monsters along the route do not stop the march (self-defense still engages ones right
+ /// next to the bot); a bot that stopped to farm would never arrive within the revenge time.
+ ///
+ /// The current map.
+ /// The token which aborts the travel wait on shutdown.
+ /// True, if the revenge march consumed this tick.
+ private async ValueTask TryRevengeMarchAsync(GameMap map, CancellationToken cancellationToken)
+ {
+ if (!this._player.TryGetRevengeDestination(map.Definition, out var deathSite))
+ {
+ return false;
+ }
+
+ if (this._player.RecentAggressor is { } aggressor
+ && ReferenceEquals(aggressor.CurrentMap, map)
+ && this._player.GetDistanceTo(aggressor.Position) <= TravelStopRange)
+ {
+ // Ran into the killer before reaching the site - that is who we came for. Stop travelling
+ // and let the combat handler (which already prioritizes the aggressor) take over.
+ this._player.ExpireRevenge("the bot caught up with its killer");
+ return true;
+ }
+
+ if (this._player.GetDistanceTo(deathSite) <= HuntingRange)
+ {
+ // Arrived. If the killer still stands here, the armed aggressor memory makes the combat
+ // handler engage; otherwise the bot simply hunts on normally from this spot.
+ this._player.ExpireRevenge("the bot arrived at the death site");
+ return false;
+ }
+
+ this._destination = deathSite;
+ this._hasDestination = true;
+ if (!await this.TravelTowardAsync(map, deathSite, cancellationToken).ConfigureAwait(false))
+ {
+ // No walkable route back (e.g. the respawn gate is across a river) - drop the attempt
+ // instead of re-issuing an impossible walk every tick.
+ this._player.ExpireRevenge("no route back to the death site");
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// The bot's routine while it takes part in a mini game event (Blood Castle, Devil Square,
+ /// Chaos Castle): fight whatever the event throws at it, keep up with the party leader on the
+ /// way through the course, and close in on the action when it moved away - nothing else. Death
+ /// and the event's end need no handling here: the engine respawns a dead bot at the map's
+ /// safezone like a player (which removes it from the event), and the event warps the remaining
+ /// participants out when it ends.
+ ///
+ private async ValueTask EvaluateInsideMiniGameAsync(GameMap map, CancellationToken cancellationToken)
+ {
+ // Keep the drinking supplies topped up - an event without potions ends quickly.
+ this._player.PendingBotActions.Enqueue(() => this.EnsurePotionsAsync());
+
+ // Fight from wherever the bot stands; the combat handler engages the targets around it.
+ this._player.HuntingOrigin = this._player.Position;
+
+ // The party boredom timer must not run down (let alone fire) mid-event; the window
+ // restarts once the event is over.
+ this._player.PartyBoredomAtUtc = null;
+
+ if (this._player.IsWalking)
+ {
+ return;
+ }
+
+ // Stay with the leader on the way through the event's course (the Blood Castle bridge) ...
+ if (this.GetPartyLeaderToFollow() is { } leader
+ && ReferenceEquals(leader.CurrentMap, map)
+ && this._player.GetDistanceTo(leader.Position) > FollowDistance)
+ {
+ await this.TravelTowardAsync(map, leader.Position, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ // ... and otherwise close in on the nearest opposition instead of idling at the entrance
+ // when the wave spawned (or the Chaos Castle crowd moved) beyond the combat range.
+ if (!map.GetAttackablesInRange(this._player.Position, TravelStopRange).Any(this.IsEventTarget)
+ && this.TryFindNearestEventTargetGround(map, out var ground))
+ {
+ await this.TravelTowardAsync(map, ground, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Whether the object is something this bot would fight inside a mini game event. Unlike in the
+ /// open world there is no safe-level filtering - the event's level bracket already matched the
+ /// bot, and the event dictates the opposition. Players count as targets only where the event
+ /// allows player killing (Chaos Castle, see ).
+ ///
+ private bool IsEventTarget(IAttackable attackable)
+ {
+ if (ReferenceEquals(attackable, this._player)
+ || !attackable.IsAlive
+ || attackable.IsAtSafezone())
+ {
+ return false;
+ }
+
+ return attackable switch
+ {
+ Monster monster => monster.Definition.ObjectKind == NpcObjectKind.Monster,
+ Player player => BotPvpRules.IsLegalPvpTarget(this._player, player),
+ _ => false,
+ };
+ }
+
+ ///
+ /// Finds the spot of a nearby opponent inside a mini game event, mirroring
+ /// (random among the two nearest, so many bots
+ /// don't dogpile the same target).
+ ///
+ private bool TryFindNearestEventTargetGround(GameMap map, out Point ground)
+ {
+ ground = default;
+ var position = this._player.Position;
+ var candidates = map.GetAttackablesInRange(position, MonsterSeekRadius)
+ .Where(this.IsEventTarget)
+ .OrderBy(a => a.GetDistanceTo(position))
+ .Take(2)
+ .ToList();
+ if (candidates.Count == 0)
+ {
+ return false;
+ }
+
+ ground = candidates.SelectRandom()!.Position;
+ return true;
+ }
+
+ ///
+ /// Gets the party leader this bot should follow, or null if the bot is solo, is the leader itself,
+ /// or the leader is currently not followable (dead, no map, or inside another mini game instance -
+ /// following into an event goes through the regular entry of ,
+ /// never through a warp).
+ ///
+ private Player? GetPartyLeaderToFollow()
+ {
+ if (this._player.Party is not { } party
+ || party.PartyMaster is not Player leader
+ || ReferenceEquals(leader, this._player)
+ || !leader.IsAlive
+ || leader.CurrentMap is null
+ || !ReferenceEquals(leader.CurrentMiniGame, this._player.CurrentMiniGame))
+ {
+ return null;
+ }
+
+ return leader;
+ }
+
+ ///
+ /// Keeps a party member with its leader: warps to the leader's map when the leader warped away,
+ /// and walks towards the leader when it moved out of the follow range.
+ ///
+ /// True, if following consumed this tick; false, if the bot is close enough and should hunt normally.
+ private async ValueTask TryFollowLeaderAsync(GameMap map, Player leader, CancellationToken cancellationToken)
+ {
+ if (!ReferenceEquals(leader.CurrentMap, map))
+ {
+ ExitGate? warpListGate = null;
+ if (leader.CurrentMap is { } targetMap
+ && !this.TryGetLegalWarpGate(targetMap.Definition, out warpListGate)
+ && targetMap.Definition.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number)
+ {
+ // The leader moved to a map the bot's plain character level cannot legally enter
+ // (level gates map access, the same rule as everywhere else). Rather than trail
+ // behind unreachable or sneak in through a back door, the bot leaves the group.
+ if (this._player.Party is { } party)
+ {
+ this._player.Logger.LogInformation(
+ "Bot {Character} leaves its party: it cannot legally follow '{Leader}' to map {Map}.",
+ this._player.Name,
+ leader.Name,
+ targetMap.Definition.Name);
+ await party.KickMySelfAsync(this._player).ConfigureAwait(false);
+ }
+
+ return true;
+ }
+
+ // Only follow a leader who SETTLED on the new map: a leader in transit (pulling a town
+ // scroll for shopping, passing through a gate, hopping back and forth) used to drag its
+ // whole party along on every hop - and since the followers land on the map's spawn gate
+ // rather than next to him, they were still walking over when he warped away again.
+ if (!this.HasLeaderSettled(leader))
+ {
+ return true;
+ }
+
+ // Prefer a spawn gate of the leader's map; maps without one (the Dungeon has no town)
+ // fall back to the warp list gate - the same spot the leader warped to. Without the
+ // fallback a follower could neither warp after its leader nor hunt (following consumed
+ // its ticks), and only the stuck watchdog kept it twitching between hunting grounds.
+ if (DateTime.UtcNow - this._lastWarpUtc >= FollowWarpCooldown
+ && leader.CurrentMap is { } leaderMap
+ && (leaderMap.Definition.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() ?? warpListGate) is { } leaderGate)
+ {
+ this._lastWarpUtc = DateTime.UtcNow;
+ this._hasDestination = false;
+ this._travelPath = null;
+ this._player.Logger.LogInformation(
+ "Bot {Character} following party leader {Leader} to map {Map}.",
+ this._player.Name,
+ leader.Name,
+ leaderMap.Definition.Name);
+ await this._player.WarpToAsync(leaderGate).ConfigureAwait(false);
+ await this.TryPersistCurrentMapAsync(leaderMap.Definition).ConfigureAwait(false);
+ }
+
+ return true;
+ }
+
+ if (this._player.GetDistanceTo(leader.Position) > FollowDistance)
+ {
+ this._destination = leader.Position;
+ this._hasDestination = true;
+ await this.TravelTowardAsync(map, leader.Position, cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Whether the leader has been on his current map long enough () for the
+ /// bot to follow him there. Tracks the leader's map per bot, so each follower makes the call on its own.
+ ///
+ private bool HasLeaderSettled(Player leader)
+ {
+ if (leader.CurrentMap?.Definition is not { } leaderMap)
+ {
+ return false;
+ }
+
+ if (this._leaderMapNumber != leaderMap.Number)
+ {
+ this._leaderMapNumber = leaderMap.Number;
+ this._leaderOnMapSinceUtc = DateTime.UtcNow;
+ }
+
+ return DateTime.UtcNow - this._leaderOnMapSinceUtc >= LeaderSettleTime;
+ }
+
+ ///
+ /// Performs the bot's character reset once it is due (reset feature enabled, required level
+ /// reached, reset limit not exhausted). The reset is not executed the moment the bot becomes
+ /// eligible: a random grace delay (..) is
+ /// rolled first, during which the bot hunts on normally. After the reset the navigation state is
+ /// cleared, so the bot starts its next cycle fresh - re-investing its points (queued by the reset
+ /// handler) and heading for a hunting ground that suits it again.
+ ///
+ /// True, if a reset was performed.
+ private async ValueTask TryResetCharacterAsync()
+ {
+ if (BotPartyHandler.HasHumanCompanion(this._player))
+ {
+ // Not in the middle of a hunting session with a player: the reset would wipe the bot's
+ // strength and teleport it home, deserting the group. It resets right after the party
+ // ends (the boredom timer of BotPartyHandler bounds how long that takes).
+ return false;
+ }
+
+ if (BotResetHandler.GetResetConfiguration(this._player.GameContext) is not { } resetConfiguration
+ || !BotResetHandler.IsResetDue(this._player, resetConfiguration))
+ {
+ this._resetDueAtUtc = null;
+ return false;
+ }
+
+ if (this._resetDueAtUtc is not { } dueAt)
+ {
+ var delay = MinResetDelay + TimeSpan.FromSeconds(Rand.NextInt(0, (int)(MaxResetDelay - MinResetDelay).TotalSeconds + 1));
+ this._resetDueAtUtc = DateTime.UtcNow + delay;
+ this._player.Logger.LogDebug(
+ "Bot {Character} reached the reset level; resetting in {Delay}.",
+ this._player.Name,
+ delay);
+ return false;
+ }
+
+ if (DateTime.UtcNow < dueAt)
+ {
+ return false;
+ }
+
+ this._resetDueAtUtc = null;
+ var payCosts = this._player.GameContext.FeaturePlugIns.GetPlugIn()?.Configuration?.BotsPayResetCosts == true;
+
+ // Queued into the MuHelper tick: the reset writes the level/reset/stat attributes, and such
+ // mutations must not race the combat handler's recalculations (see PendingBotActions). The
+ // queued call re-validates the eligibility itself; if it refuses (e.g. unaffordable costs
+ // when BotsPayResetCosts is enabled), the check above re-schedules a fresh grace delay.
+ var player = this._player;
+ player.PendingBotActions.Enqueue(async () =>
+ {
+ await BotResetHandler.TryResetAsync(player, resetConfiguration, payCosts).ConfigureAwait(false);
+ });
+
+ // Start the next cycle fresh: no stale destination or route, and no warp cooldown - a freshly
+ // reset player heads straight back out, and the point pool (queued by the reset handler into
+ // the bot's AI tick) is invested before the next evaluation gets this far. Cleared
+ // optimistically - if the queued reset refuses, the bot merely re-picks its hunting ground.
+ this._hasDestination = false;
+ this._travelPath = null;
+ this._emptyGroundSince = null;
+ this._lastWarpUtc = DateTime.MinValue;
+ return true;
+ }
+
+ ///
+ /// Warps to the map that offers the strongest monsters this bot can still safely handle, so it
+ /// earns the best experience without being slaughtered. It arrives at the destination safezone
+ /// and walks out to a hunting ground like a real player.
+ ///
+ /// True, if a warp was performed.
+ private async ValueTask TryWarpToBetterMapAsync()
+ {
+ var botLevel = this.GetBotLevel();
+ var plainLevel = (int)(this._player.Attributes?[Stats.Level] ?? 1);
+
+ // Two situations force the bot OFF its current map, bypassing the usual minimum warp level:
+ // a map without a single safe monster is hostile territory (aggressive monsters kill the bot
+ // while it cannot fight back), and a map the bot could not legally warp to with its plain
+ // level is off-limits regardless of its strength - a real character of that level cannot be
+ // there, so neither may the bot (e.g. a veteran stranded on a high map after its reset).
+ var currentMap = this._player.CurrentMap?.Definition;
+ var mapIsHostile = currentMap is not null && this.BestSafeLevel(currentMap) == 0;
+ var mapIsIllegal = currentMap is not null
+ && !this.TryGetLegalWarpGate(currentMap, out _)
+ && currentMap.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number;
+ var mustEscape = mapIsHostile || mapIsIllegal;
+
+ if ((plainLevel < MinWarpLevel && !mustEscape)
+ || DateTime.UtcNow - this._lastWarpUtc < WarpCooldown)
+ {
+ return false;
+ }
+
+ // When escaping, any legal map with something safe to hunt beats staying - and with no legal
+ // warp target at all (a freshly reset veteran may be below every warp requirement), the bot
+ // retreats to its class home town, like a player using a town scroll.
+ if (!this.TryPickBetterMap(mustEscape, out var targetGate, out var targetMap, out var targetLevel)
+ && !(mustEscape && this.TryGetHomeEscapeGate(out targetGate, out targetMap, out targetLevel)))
+ {
+ return false;
+ }
+
+ this._lastWarpUtc = DateTime.UtcNow;
+ this._hasDestination = false;
+ this._travelPath = null;
+ this._player.Logger.LogInformation(
+ "Bot {Character} (level {Level}) warping to map {Map} (monsters ~{MonsterLevel}).",
+ this._player.Name,
+ botLevel,
+ targetMap.Name,
+ targetLevel);
+ await this._player.WarpToAsync(targetGate).ConfigureAwait(false);
+ await this.TryPersistCurrentMapAsync(targetMap).ConfigureAwait(false);
+ return true;
+ }
+
+ ///
+ /// Walks the next stretch towards the destination. A full route is resolved with a heuristic search
+ /// (so it goes around walls and out of the town gate) and the first steps are
+ /// issued; the remainder is recomputed from the new position on the next tick. The safe zone is allowed
+ /// in the path so the bot can leave town.
+ ///
+ private async ValueTask TravelTowardAsync(GameMap map, Point destination, CancellationToken cancellationToken)
+ {
+ var position = this._player.Position;
+
+ // Follow the cached route as long as it still leads to (roughly) the same destination and the
+ // bot is still on it. Re-planning a whole-map A* every tick just to consume three steps wasted
+ // CPU and starved the shared path finder pool once dozens of bots travelled at the same time.
+ if (this._travelPath is { } cached
+ && this._travelPathIndex < cached.Count
+ && destination.EuclideanDistanceTo(this._travelPathTarget) <= PathReuseTolerance
+ && position.EuclideanDistanceTo(cached[this._travelPathIndex].Point) <= 1.5)
+ {
+ await this.WalkCachedStepsAsync(position).ConfigureAwait(false);
+ return true;
+ }
+
+ this._travelPath = null;
+
+ IList? path;
+
+ // Observe the navigator's shutdown token while queuing for a shared path finder: on dispose the
+ // wait is abandoned at once (the OperationCanceledException is expected and handled in
+ // SafeEvaluateAsync) instead of holding the tick until a finder frees up.
+ await TravelPathFinderPool.WaitAsync(cancellationToken).ConfigureAwait(false);
+ PathFinder? finder = null;
+ try
+ {
+ if (!TravelPathFinders.TryTake(out finder))
+ {
+ finder = CreateTravelPathFinder();
+ }
+
+ finder.ResetPathFinder();
+ path = finder.FindPath(position, destination, map.Terrain.AIgrid, true);
+ }
+ finally
+ {
+ if (finder is not null)
+ {
+ TravelPathFinders.Add(finder);
+ }
+
+ TravelPathFinderPool.Release();
+ }
+
+ if (path is null || path.Count == 0)
+ {
+ // No route to this ground (e.g. across a river): report it so the navigator picks another one
+ // straight away instead of standing still re-issuing an impossible walk.
+ return false;
+ }
+
+ this._travelPath = path;
+ this._travelPathIndex = 0;
+ this._travelPathTarget = destination;
+ await this.WalkCachedStepsAsync(position).ConfigureAwait(false);
+ return true;
+ }
+
+ ///
+ /// Issues the next few steps of the cached route (short hops, so the bot can stop and fight between
+ /// them) and advances the route cursor.
+ ///
+ private async ValueTask WalkCachedStepsAsync(Point position)
+ {
+ var path = this._travelPath!;
+ var stepsCount = Math.Min(path.Count - this._travelPathIndex, StepsPerHop);
+ var steps = new WalkingStep[stepsCount];
+ for (var i = 0; i < stepsCount; i++)
+ {
+ var node = path[this._travelPathIndex + i];
+ var previous = i == 0 ? position : steps[i - 1].To;
+ steps[i] = new WalkingStep(previous, node.Point, previous.GetDirectionTo(node.Point));
+ }
+
+ this._travelPathIndex += stepsCount;
+ await this._player.WalkToAsync(steps[stepsCount - 1].To, steps).ConfigureAwait(false);
+ }
+
+ ///
+ /// Picks a hunting ground and starts walking to it. If the chosen ground turns out to be unreachable the
+ /// destination is dropped, so the next call (next tick) simply picks another one.
+ ///
+ private async ValueTask PickGroundAndTravelAsync(GameMap map, CancellationToken cancellationToken)
+ {
+ if (!this.TryPickHuntingGround(map, out var ground, out var groundLevel))
+ {
+ this._hasDestination = false;
+ this._player.Logger.LogDebug("Bot {Character}: no hunting ground found on map {Map}.", this._player.Name, map.Definition.Name);
+ return;
+ }
+
+ this._destination = ground;
+ this._hasDestination = true;
+ this._player.Logger.LogInformation(
+ "Bot {Character} (level {Level}) heading to hunting ground {Ground} (monster level ~{MonsterLevel}).",
+ this._player.Name,
+ this.GetBotLevel(),
+ ground,
+ groundLevel);
+
+ if (!await this.TravelTowardAsync(map, ground, cancellationToken).ConfigureAwait(false))
+ {
+ this._hasDestination = false;
+ }
+ }
+
+ ///
+ /// Ensures the bot keeps a stack of healing and a stack of mana potions in its inventory. Creates the
+ /// stacks the first time (covering bots generated before they were granted) and refills them once they
+ /// run low, so the bot never goes without a way to heal or to keep casting.
+ ///
+ private async ValueTask EnsurePotionsAsync()
+ {
+ await this.EnsurePotionStackAsync(HealPotionNumber).ConfigureAwait(false);
+ await this.EnsurePotionStackAsync(ManaPotionNumber).ConfigureAwait(false);
+ await this.EnsureAmmunitionAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Keeps an archer's ammunition topped up. With the starter bow the offline attack path does not
+ /// consume arrows, but an upgraded bow can carry an ammunition consumption rate - without this the
+ /// bot would eventually shoot its quiver empty and its bow skills would stop working.
+ ///
+ private async ValueTask EnsureAmmunitionAsync()
+ {
+ if (this._player.Inventory is not { } inventory)
+ {
+ return;
+ }
+
+ if (inventory.EquippedAmmunitionItem is { } ammo)
+ {
+ if (ammo.Durability < HealPotionRefillBelow && ammo.Definition is { } ammoDefinition)
+ {
+ ammo.Durability = Math.Max((byte)1, ammoDefinition.Durability);
+ }
+
+ return;
+ }
+
+ // Ammo ran out completely (an empty stack gets destroyed): if a bow is equipped, put a fresh
+ // stack of arrows into the other hand.
+ var bow = inventory.EquippedItems.FirstOrDefault(i =>
+ i.Definition is { Group: 4, IsAmmunition: false });
+ if (bow is null)
+ {
+ return;
+ }
+
+ var arrows = this._player.GameContext.Configuration.Items
+ .FirstOrDefault(d => d.Group == 4 && d.Number == 15);
+ if (arrows is null)
+ {
+ return;
+ }
+
+ var item = this._player.PersistenceContext.CreateNew
- ();
+ item.Definition = arrows;
+ item.Durability = Math.Max((byte)1, arrows.Durability);
+ var targetSlot = bow.ItemSlot == InventoryConstants.RightHandSlot
+ ? InventoryConstants.LeftHandSlot
+ : InventoryConstants.RightHandSlot;
+ if (!await inventory.AddItemAsync(targetSlot, item).ConfigureAwait(false))
+ {
+ await this._player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false);
+ }
+ }
+
+ private async ValueTask EnsurePotionStackAsync(int potionNumber)
+ {
+ if (this._player.Inventory is not { } inventory
+ || this._player.GameContext.Configuration.Items
+ .FirstOrDefault(d => d.Group == HealPotionGroup && d.Number == potionNumber) is not { } definition)
+ {
+ return;
+ }
+
+ var potions = inventory.Items
+ .Where(i => i.Definition?.Group == HealPotionGroup && i.Definition.Number == potionNumber)
+ .ToList();
+ var charges = potions.Sum(p => (int)p.Durability);
+ if (charges >= HealPotionRefillBelow)
+ {
+ return;
+ }
+
+ // A stack holds as many charges as the item definition allows - no more (see EmergencyPotionCharges).
+ var stackSize = Math.Max((byte)1, definition.Durability);
+ foreach (var potion in potions.Where(p => p.Durability < stackSize))
+ {
+ charges += (int)(stackSize - potion.Durability);
+ potion.Durability = stackSize;
+ if (charges >= EmergencyPotionCharges)
+ {
+ return;
+ }
+ }
+
+ while (charges < EmergencyPotionCharges)
+ {
+ var item = this._player.PersistenceContext.CreateNew
- ();
+ item.Definition = definition;
+ item.Durability = stackSize;
+ if (!await inventory.AddItemAsync(item).ConfigureAwait(false))
+ {
+ // Backpack full - drop the throw-away item again; what is stocked has to do.
+ await this._player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false);
+ return;
+ }
+
+ charges += stackSize;
+ }
+ }
+
+ ///
+ /// Finds the position of the nearest live, attackable monster within that
+ /// the bot can safely fight, so the bot heads straight for a real monster instead of a random tile in a
+ /// huge spawn area. Returns false when none are loaded nearby (or all are above the safe level cap), in
+ /// which case the caller falls back to coarse spawn-area travel / warping to relocate.
+ ///
+ private bool TryFindNearestMonsterGround(GameMap map, out Point ground)
+ {
+ ground = default;
+ var position = this._player.Position;
+ var candidates = new List<(Monster Monster, double Distance)>();
+ foreach (var attackable in map.GetAttackablesInRange(position, MonsterSeekRadius))
+ {
+ if (attackable is not Monster monster
+ || !monster.IsAlive
+ || monster.IsAtSafezone())
+ {
+ continue;
+ }
+
+ if (!CombatHandler.IsSafeTarget(this._player, monster.Definition))
+ {
+ // Too dangerous (judged by its real damage, not its level) - leave it and let the warp /
+ // area logic relocate the bot to a map whose monsters it can handle.
+ continue;
+ }
+
+ candidates.Add((monster, monster.GetDistanceTo(position)));
+ }
+
+ if (candidates.Count == 0)
+ {
+ return false;
+ }
+
+ // Head for one of the few nearest monsters, chosen randomly, instead of strictly the nearest:
+ // with many bots on one ground, deterministic nearest-first would send them all to the same
+ // monster and they would roam the map as one pack - a very bot-like look.
+ var chosen = candidates
+ .OrderBy(c => c.Distance)
+ .Take(3)
+ .Select(c => c.Monster)
+ .SelectRandom();
+ if (chosen is null)
+ {
+ return false;
+ }
+
+ // The monster stands on a walkable tile, so head straight for it; TravelTowardAsync walks a few steps
+ // per tick and re-homes as the monster roams, closing the gap until combat range is reached.
+ ground = chosen.Position;
+ return true;
+ }
+
+ ///
+ /// Re-resolves the warp destination map through the bot's own persistence context and assigns that
+ /// instance to the character. The warp itself assigns the GLOBAL configuration instance, which the
+ /// bot's context does not track - the CurrentMapId foreign key silently never got saved, so a
+ /// restarted bot always woke up on its home map and warped all over again (warp burst on restart).
+ ///
+ private async ValueTask TryPersistCurrentMapAsync(GameMapDefinition mapDefinition)
+ {
+ try
+ {
+ if (this._player.SelectedCharacter is not { } character)
+ {
+ return;
+ }
+
+ var ownInstance = await this._player.PersistenceContext
+ .GetByIdAsync(mapDefinition.GetId()).ConfigureAwait(false);
+ if (ownInstance is not null)
+ {
+ character.CurrentMap = ownInstance;
+ }
+ }
+ catch (Exception ex)
+ {
+ // Not worth failing the warp over - worst case the map is (as before) not persisted.
+ this._player.Logger.LogDebug(ex, "Could not persist current map for bot {Character}.", this._player.Name);
+ }
+ }
+
+ ///
+ /// The bot's reset-aware effective level (see ),
+ /// used for hunting decisions (which monsters pay off). Map ACCESS is deliberately not decided
+ /// by this - the game gates warps by the plain character level (see ),
+ /// and the bots must obey the same rule.
+ ///
+ private int GetBotLevel() => BotResetHandler.GetEffectiveLevel(this._player);
+
+ ///
+ /// Gets the gate through which the bot may legally warp to the given map, exactly like a player
+ /// using the in-game warp window: the map must have an entry in the server's warp list whose level
+ /// requirement (class-reduced, see )
+ /// is met by the bot's PLAIN character level. The reset-aware effective level intentionally plays
+ /// no role here - a freshly reset veteran cannot warp to high maps either, no matter its strength.
+ ///
+ /// The target map.
+ /// The warp target gate, if a legal entry exists.
+ /// True, if the bot may warp to the map.
+ private bool TryGetLegalWarpGate(GameMapDefinition mapDefinition, [MaybeNullWhen(false)] out ExitGate gate)
+ {
+ gate = null;
+ if (this._player.SelectedCharacter is not { } character)
+ {
+ return false;
+ }
+
+ var plainLevel = (int)(this._player.Attributes?[Stats.Level] ?? 1);
+ gate = this._player.GameContext.Configuration.WarpList
+ .Where(w => w.Gate?.Map?.Number == mapDefinition.Number
+ && character.GetEffectiveMoveLevelRequirement(w.LevelRequirement) <= plainLevel)
+ .Select(w => w.Gate!)
+ .FirstOrDefault();
+ return gate is not null;
+ }
+
+ ///
+ /// Gets the escape gate to the bot's class home town, for when it must leave its current map but is
+ /// below every warp requirement (e.g. a freshly reset veteran) - the equivalent of a town scroll.
+ ///
+ /// The home map safezone gate.
+ /// The home map.
+ /// The level of the strongest safe monster there (informational).
+ /// True, if the bot has a home map it is not already on.
+ private bool TryGetHomeEscapeGate([MaybeNullWhen(false)] out ExitGate gate, [MaybeNullWhen(false)] out GameMapDefinition mapDefinition, out int monsterLevel)
+ {
+ gate = null;
+ mapDefinition = null;
+ monsterLevel = 0;
+
+ var homeMap = this._player.SelectedCharacter?.CharacterClass?.HomeMap;
+ if (homeMap is null
+ || homeMap.Number == this._player.CurrentMap?.Definition.Number
+ || homeMap.GetSafezoneGate() is not { } safezoneGate)
+ {
+ return false;
+ }
+
+ gate = safezoneGate;
+ mapDefinition = homeMap;
+ monsterLevel = this.BestSafeLevel(homeMap);
+ return true;
+ }
+
+ ///
+ /// The lowest monster level which still earns this bot anything: a master-class character at the
+ /// game's maximum level gains master experience only from monsters of at least
+ /// GameConfiguration.MinimumMonsterLevelForMasterExperience (the experience path grants it
+ /// nothing at all below that) and no regular experience either, because it is at the maximum level
+ /// already. Hunting below that floor is therefore
+ /// not the safe choice for a mastered bot, it is a dead end - so the map and hunting ground choice
+ /// look above the floor first. 0 for every other bot, which hunts by safety alone.
+ ///
+ private int MasterExperienceFloor()
+ {
+ var configuration = this._player.GameContext.Configuration;
+ if (this._player.SelectedCharacter?.CharacterClass?.IsMasterClass != true
+ || (this._player.Attributes?[Stats.Level] ?? 0) < configuration.MaximumLevel)
+ {
+ return 0;
+ }
+
+ return configuration.MinimumMonsterLevelForMasterExperience;
+ }
+
+ ///
+ /// The level of the strongest monster on the map which this bot can safely fight (judged by the
+ /// monster's real damage against the bot's defense and health, see );
+ /// 0 if the map has none. The level of the safe monsters remains the measure of a map's experience
+ /// value when comparing maps - it just no longer decides what is SAFE. Monsters below
+ /// are not counted at all, which lets a mastered bot judge a map by
+ /// what it actually earns there (see ).
+ ///
+ /// The map to judge.
+ /// The lowest monster level worth counting.
+ private int BestSafeLevel(GameMapDefinition mapDefinition, int minimumLevel = 0)
+ {
+ var best = 0;
+ foreach (var area in mapDefinition.MonsterSpawns)
+ {
+ if (!this.IsPermanentMonsterSpawn(area))
+ {
+ continue;
+ }
+
+ var level = GetMonsterLevel(area.MonsterDefinition!);
+ if (level >= minimumLevel && level > best && CombatHandler.IsSafeTarget(this._player, area.MonsterDefinition!))
+ {
+ best = level;
+ }
+ }
+
+ return best;
+ }
+
+ ///
+ /// Whether the spawn area holds monsters which actually stand on the map at all times. The event
+ /// spawns (golden monsters, the invasion and wave areas) are configured on the regular maps too, but
+ /// only exist while their event runs - a bot judging a map by them would travel to hunting grounds
+ /// which are empty most of the day, and would think its current map holds far stronger monsters than
+ /// it can find there. Same class of mistake as walking to a merchant that is only spawned now and
+ /// then, see .
+ ///
+ private bool IsPermanentMonsterSpawn(MonsterSpawnArea area)
+ {
+ return area is { Quantity: > 0, SpawnTrigger: SpawnTrigger.Automatic, MonsterDefinition.ObjectKind: NpcObjectKind.Monster };
+ }
+
+ ///
+ /// Picks the legally warpable map (other than the current one) that offers the strongest monsters the
+ /// bot can still safely handle, if it is meaningfully better than the current map, with its warp gate.
+ /// In escape mode (fleeing a hostile or illegal map) any legal map with something safe to hunt counts,
+ /// no matter how it compares to the current one.
+ ///
+ private bool TryPickBetterMap(bool escape, out ExitGate gate, out GameMapDefinition mapDefinition, out int monsterLevel)
+ {
+ // A mastered bot earns nothing below the master-experience floor, so it first looks for a map
+ // which pays at all. Only when no map within its reach offers monsters above the floor that it
+ // can safely fight does it fall back to hunting by safety alone: standing on a map it cannot
+ // survive would not earn it master experience either, and its gear still improves meanwhile.
+ var floor = this.MasterExperienceFloor();
+ return (floor > 0 && this.TryPickBetterMapCore(escape, floor, out gate, out mapDefinition, out monsterLevel))
+ || this.TryPickBetterMapCore(escape, 0, out gate, out mapDefinition, out monsterLevel);
+ }
+
+ ///
+ /// Picks the best map (see ), counting only monsters of at least
+ /// .
+ ///
+ private bool TryPickBetterMapCore(bool escape, int minimumMonsterLevel, out ExitGate gate, out GameMapDefinition mapDefinition, out int monsterLevel)
+ {
+ gate = default!;
+ mapDefinition = default!;
+ monsterLevel = 0;
+
+ var current = this._player.CurrentMap?.Definition;
+ var threshold = escape ? 1 : (current is null ? 0 : this.BestSafeLevel(current, minimumMonsterLevel)) + WarpImprovementMargin;
+
+ foreach (var candidate in this._player.GameContext.Configuration.Maps)
+ {
+ if (ReferenceEquals(candidate, current))
+ {
+ continue;
+ }
+
+ var best = this.BestSafeLevel(candidate, minimumMonsterLevel);
+ if (best < threshold || candidate.TryGetRequirementError(this._player, out _))
+ {
+ continue;
+ }
+
+ if (this.TryGetLegalWarpGate(candidate, out var warpGate))
+ {
+ gate = warpGate;
+ mapDefinition = candidate;
+ monsterLevel = best;
+ threshold = best + 1; // keep only a strictly-stronger map after this one
+ }
+ }
+
+ return mapDefinition is not null;
+ }
+
+ ///
+ /// Picks a hunting ground point from the map's monster spawn areas, preferring the strongest monsters
+ /// the bot can safely fight (see ) - best experience without
+ /// being slaughtered. If nothing on the map is safe, it falls back to the weakest monsters available
+ /// (the bot should warp away anyway).
+ ///
+ private bool TryPickHuntingGround(GameMap map, out Point ground, out int groundLevel)
+ {
+ ground = default;
+ groundLevel = 0;
+
+ var candidates = map.Definition.MonsterSpawns
+ .Where(this.IsPermanentMonsterSpawn)
+ .Select(a => (Area: a, Level: GetMonsterLevel(a.MonsterDefinition!)))
+ .Where(x => x.Level > 0)
+ .ToList();
+ if (candidates.Count == 0)
+ {
+ return false;
+ }
+
+ // Prefer the strongest monsters the bot can safely handle (best experience); if none on this map
+ // pass the safety check, fall back to the weakest available (the bot should warp away anyway).
+ var safe = candidates.Where(x => CombatHandler.IsSafeTarget(this._player, x.Area.MonsterDefinition!)).ToList();
+
+ // A mastered bot only earns master experience above the floor (see MasterExperienceFloor), so as
+ // long as this map holds such monsters it hunts them - the weaker grounds would pay it nothing.
+ // Above the floor it then takes the WEAKEST monsters rather than the strongest: master experience
+ // hardly grows with the monster's level, so the cheapest kill above the floor is the best one.
+ var floor = this.MasterExperienceFloor();
+ var masterGrounds = floor > 0 ? safe.Where(x => x.Level >= floor).ToList() : [];
+
+ List<(MonsterSpawnArea Area, int Level)> band;
+ if (masterGrounds.Count > 0)
+ {
+ var cheapest = masterGrounds.Min(x => x.Level);
+ band = masterGrounds.Where(x => x.Level <= cheapest + BandWidth).ToList();
+ }
+ else if (safe.Count > 0)
+ {
+ var top = safe.Max(x => x.Level);
+ band = safe.Where(x => x.Level >= top - BandWidth).ToList();
+ }
+ else
+ {
+ var bottom = candidates.Min(x => x.Level);
+ band = candidates.Where(x => x.Level <= bottom + BandWidth).ToList();
+ }
+
+ // After repeated deaths against the same player, the area around the death site is off the
+ // menu for a while (see OfflinePlayer.TryGetDeathSiteToAvoid): drop grounds close to it, so
+ // the bot farms somewhere else instead of walking back into the same lost fight. Only if
+ // EVERY candidate lies near the death site are they all kept - relocating within the band is
+ // still better than not picking any ground at all (the point pick below still steers away).
+ Point? siteToAvoid = this._player.TryGetDeathSiteToAvoid(map.Definition, out var avoidPoint) ? avoidPoint : null;
+ if (siteToAvoid is { } avoid)
+ {
+ var farGrounds = band.Where(x => GroundDistance(x.Area, avoid) > DeathSiteAvoidanceRange).ToList();
+ if (farGrounds.Count > 0)
+ {
+ band = farGrounds;
+ }
+ }
+
+ // Weight the choice by spawn quantity and proximity, so the bot prefers nearby, dense grounds
+ // (shorter, safer travel) while keeping some variety.
+ var position = this._player.Position;
+ var chosen = band.SelectWeightedRandom(band.Select(x => GroundWeight(x.Area, position)));
+ if (chosen.Area is null)
+ {
+ return false;
+ }
+
+ groundLevel = chosen.Level;
+ return this.TryPickWalkablePoint(map, chosen.Area, siteToAvoid, out ground);
+ }
+
+ private bool TryPickWalkablePoint(GameMap map, MonsterSpawnArea area, Point? avoidCenter, out Point point)
+ {
+ var minX = Math.Min(area.X1, area.X2);
+ var maxX = Math.Max(area.X1, area.X2);
+ var minY = Math.Min(area.Y1, area.Y2);
+ var maxY = Math.Max(area.Y1, area.Y2);
+
+ for (var attempt = 0; attempt < MaxPointPickAttempts; attempt++)
+ {
+ var x = Rand.NextInt(minX, maxX + 1);
+ var y = Rand.NextInt(minY, maxY + 1);
+ if (!map.Terrain.WalkMap[x, y] || map.Terrain.SafezoneMap[x, y])
+ {
+ continue;
+ }
+
+ // MU spawn areas can span most of the map, so a random point of an otherwise "far" area
+ // may still land right at the death site to avoid - re-roll such points. If the area
+ // offers nothing else, the attempts run out and the pick fails like for any other
+ // unusable area (the caller then simply retries next tick).
+ if (avoidCenter is { } avoid
+ && Math.Abs(x - avoid.X) + Math.Abs(y - avoid.Y) <= DeathSiteAvoidanceRange)
+ {
+ continue;
+ }
+
+ point = new Point((byte)x, (byte)y);
+ return true;
+ }
+
+ point = default;
+ return false;
+ }
+
+ ///
+ /// A Manhattan distance heuristic (the path finder applies its own estimate multiplier), used to turn the
+ /// otherwise unguided search into A* for long-distance bot travel.
+ ///
+ private sealed class ManhattanTravelHeuristic : IHeuristic
+ {
+ public int HeuristicEstimateMultiplier { get; set; }
+
+ public int CalculateHeuristicDistance(Point location, Point target)
+ => this.HeuristicEstimateMultiplier * (Math.Abs(location.X - target.X) + Math.Abs(location.Y - target.Y));
+ }
+}
diff --git a/src/GameLogic/Bots/BotPartyHandler.cs b/src/GameLogic/Bots/BotPartyHandler.cs
new file mode 100644
index 000000000..c93e17bab
--- /dev/null
+++ b/src/GameLogic/Bots/BotPartyHandler.cs
@@ -0,0 +1,222 @@
+//
+// 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;
+
+///
+/// Lets a server-side bot party up with players who invite it (enabled by
+/// ): the invitation is accepted after a short
+/// human-like delay, and the bot then follows the leader like any party member (see the follow logic
+/// in ) until it gets bored and politely leaves. Safeguards keep it
+/// believable and abuse-free: no grouping across an absurd level gap, no acceptance while the bot is
+/// on an errand (shopping trip) or has unfinished business (revenge), and the invitation is
+/// re-validated when the delay has passed - the inviter may have joined another party or left.
+///
+internal static class BotPartyHandler
+{
+ ///
+ /// The maximum difference of the reset-aware effective level (see
+ /// ) between the bot and the inviter. Within one
+ /// reset worth of levels plus some slack, hunting together still makes sense for both; grouping a
+ /// fresh character with a 15-resets veteran would only be a power-leveling service. On servers
+ /// without the reset feature the plain levels always lie within this bound, matching OpenMU's own
+ /// party action, which has no level gate at all.
+ ///
+ private const int MaxEffectiveLevelGap = 500;
+
+ /// Lower bound of the human-like delay before the bot answers an invitation.
+ private static readonly TimeSpan MinAcceptDelay = TimeSpan.FromSeconds(2);
+
+ /// Upper bound of the human-like delay before the bot answers an invitation.
+ private static readonly TimeSpan MaxAcceptDelay = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Lower bound of the time the bot stays in a party with a human before it gets bored and leaves.
+ /// A player who groups a bot gets a companion for a decent hunting session, but not a permanent
+ /// follower - the bot has its own goals (its resets, its shopping, its own pace).
+ ///
+ private static readonly TimeSpan MinPartyDuration = TimeSpan.FromMinutes(10);
+
+ /// Upper bound of the time the bot stays in a party with a human, see .
+ private static readonly TimeSpan MaxPartyDuration = TimeSpan.FromMinutes(20);
+
+ ///
+ /// Schedules the acceptance of a party invitation to a bot, if the bot is available for it.
+ /// Called from the auto-accept criteria of .
+ ///
+ /// The invited player; only server-side bots schedule an accept.
+ /// The player who sent the party request.
+ /// Overrides the human-like random delay (used by tests).
+ /// True, if the invitation was taken and will be answered; false, if no criteria matched.
+ internal static async ValueTask TryScheduleAcceptAsync(Player receiver, Player requester, TimeSpan? acceptDelay = null)
+ {
+ if (receiver is not OfflinePlayer bot
+ || bot.Account?.IsBot != true
+ || HasHumanCompanion(bot)
+ || bot.PendingPartyInvite is not null)
+ {
+ return false;
+ }
+
+ if (bot.IsOnShoppingTrip || 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.
+ return false;
+ }
+
+ if (!IsRequesterEligible(bot, requester))
+ {
+ return false;
+ }
+
+ var delay = acceptDelay
+ ?? MinAcceptDelay + TimeSpan.FromMilliseconds(Rand.NextInt(0, (int)(MaxAcceptDelay - MinAcceptDelay).TotalMilliseconds + 1));
+
+ // Blocks a second concurrent inviter (the request action treats a set requester like a busy
+ // player) and is cleared again when the invitation is answered or dropped.
+ bot.LastPartyRequester = requester;
+ bot.PendingPartyInvite = new PendingPartyInvite(requester, DateTime.UtcNow + delay);
+
+ // The same feedback a human invitee's request flow gives, so the inviter knows it went out.
+ await requester.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.RequestedPlayerForParty), bot.Name).ConfigureAwait(false);
+ bot.Logger.LogInformation("Bot '{Name}' accepts the party invitation of '{Requester}' in {Delay}.", bot.Name, requester.Name, delay);
+ return true;
+ }
+
+ ///
+ /// Drives the bot's party behavior; called from the bot's regular evaluation tick. Answers a
+ /// pending invitation once its delay passed, and leaves the party again when the bot got bored
+ /// of grouping with a human (bot-only parties are exempt - they are managed by the hourly
+ /// re-formation of ).
+ ///
+ /// The bot.
+ internal static async ValueTask ProcessAsync(OfflinePlayer bot)
+ {
+ if (bot.PendingPartyInvite is { } invite && DateTime.UtcNow >= invite.AcceptAtUtc)
+ {
+ bot.PendingPartyInvite = null;
+ try
+ {
+ await AcceptInvitationAsync(bot, invite.Requester).ConfigureAwait(false);
+ }
+ finally
+ {
+ bot.LastPartyRequester = null;
+ }
+ }
+
+ if (bot.Party is { } party && HasHumanCompanion(bot))
+ {
+ bot.PartyBoredomAtUtc ??= DateTime.UtcNow + MinPartyDuration
+ + TimeSpan.FromSeconds(Rand.NextInt(0, (int)(MaxPartyDuration - MinPartyDuration).TotalSeconds + 1));
+ if (DateTime.UtcNow >= bot.PartyBoredomAtUtc)
+ {
+ bot.PartyBoredomAtUtc = null;
+ bot.Logger.LogInformation("Bot '{Name}' got bored and leaves its party.", bot.Name);
+ await party.KickMySelfAsync(bot).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ bot.PartyBoredomAtUtc = null;
+ }
+ }
+
+ ///
+ /// Determines whether the bot's party contains a human player (any live member which is not a
+ /// server-side ).
+ ///
+ /// The bot.
+ /// True, if a human player is in the bot's party.
+ internal static bool HasHumanCompanion(Player bot)
+ {
+ return bot.Party is { } party
+ && party.PartyList.OfType().Any(member => member is not OfflinePlayer);
+ }
+
+ private static async ValueTask AcceptInvitationAsync(OfflinePlayer bot, Player requester)
+ {
+ // Re-validate: between the invitation and this answer, the bot may have joined a human's party
+ // and the inviter may have died, left the game or joined another party.
+ if (HasHumanCompanion(bot) || !IsRequesterEligible(bot, requester))
+ {
+ bot.Logger.LogInformation("Bot '{Name}' dropped the party invitation of '{Requester}' - the situation changed.", bot.Name, requester.Name);
+ return;
+ }
+
+ await LeaveBotPartyAsync(bot).ConfigureAwait(false);
+ if (bot.Party is not null)
+ {
+ bot.Logger.LogInformation("Bot '{Name}' could not leave its bot party for '{Requester}'.", bot.Name, requester.Name);
+ return;
+ }
+
+ bool success;
+ if (requester.Party is { } requesterParty)
+ {
+ if (!Equals(requesterParty.PartyMaster, requester))
+ {
+ // The inviter joined another party as a plain member in the meantime; it can no
+ // longer take the bot in.
+ return;
+ }
+
+ success = await requesterParty.AddAsync(bot).ConfigureAwait(false);
+ }
+ else
+ {
+ // Like the regular party response: the requester becomes the master of the new party.
+ var party = bot.GameContext.PartyManager.CreateParty();
+ success = await party.AddAsync(requester).ConfigureAwait(false)
+ && await party.AddAsync(bot).ConfigureAwait(false);
+ }
+
+ if (success)
+ {
+ bot.Logger.LogInformation("Bot '{Name}' joined the party of '{Requester}'.", bot.Name, requester.Name);
+ }
+ }
+
+ ///
+ /// Lets the bot leave the bot-only party it hunts in, so it can join the player who invited it: a
+ /// living player takes precedence over the bot's own company. When the bot LEADS that party, the
+ /// group is broken up instead - the engine does not hand the mastership over to another member when
+ /// the master leaves (it only removes them from the member list), which would leave the remaining
+ /// bots following a leader who is not in their party anymore. Their next hourly re-formation groups
+ /// them again (see ).
+ ///
+ private static async ValueTask LeaveBotPartyAsync(OfflinePlayer bot)
+ {
+ if (bot.Party is not { } party)
+ {
+ return;
+ }
+
+ if (Equals(party.PartyMaster, bot))
+ {
+ bot.Logger.LogInformation("Bot '{Name}' breaks up its bot party to join a player.", bot.Name);
+ foreach (var member in party.PartyList.ToList())
+ {
+ await party.KickMySelfAsync(member).ConfigureAwait(false);
+ }
+
+ return;
+ }
+
+ await party.KickMySelfAsync(bot).ConfigureAwait(false);
+ }
+
+ private static bool IsRequesterEligible(OfflinePlayer bot, Player requester)
+ {
+ if (!requester.IsAlive || requester.PlayerState.CurrentState != PlayerState.EnteredWorld)
+ {
+ return false;
+ }
+
+ var levelGap = Math.Abs(BotResetHandler.GetEffectiveLevel(bot) - BotResetHandler.GetEffectiveLevel(requester));
+ return levelGap <= MaxEffectiveLevelGap;
+ }
+}
diff --git a/src/GameLogic/Bots/BotPlayer.cs b/src/GameLogic/Bots/BotPlayer.cs
new file mode 100644
index 000000000..194071c03
--- /dev/null
+++ b/src/GameLogic/Bots/BotPlayer.cs
@@ -0,0 +1,115 @@
+//
+// 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.GameLogic.Offline;
+
+///
+/// A connection-less bot player. It reuses the whole offline-player intelligence (combat, buffs,
+/// healing, pickup) and adds a which makes it roam to level-appropriate
+/// hunting grounds instead of standing on a fixed spawn position.
+///
+public sealed class BotPlayer : OfflinePlayer
+{
+ ///
+ /// After this many AI ticks failing in a row, the bot is considered broken and gets restarted.
+ /// The engine's attribute system is not thread-safe, and a lost race can corrupt a character's
+ /// attribute graph for good: every following tick throws, the bot stops playing and floods the log
+ /// with the same exception until the server restarts. A fresh login rebuilds the graph and heals it,
+ /// which is exactly what a player would do. The threshold is high enough that a single failing tick
+ /// (a transient race, a monster which just died) is simply skipped, like before.
+ ///
+ private const int ConsecutiveFailuresUntilRestart = 20;
+
+ private BotNavigator? _navigator;
+
+ private int _consecutiveTickFailures;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The game context.
+ public BotPlayer(IGameContext gameContext)
+ : base(gameContext)
+ {
+ }
+
+ ///
+ public override bool RespawnAndContinue => true;
+
+ ///
+ /// Gets or sets a value indicating whether this bot evolved into its master class and still needs
+ /// the "relog" which mounts the master attributes (see ).
+ /// Set from the bot's own tick, where the evolution runs; acted upon by the maintenance pass, which
+ /// is the only place allowed to restart a bot (a restart from within the bot's own timer callback
+ /// would tear down the very loop it runs in).
+ ///
+ public bool AwaitsMasterRestart { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this bot's AI keeps failing and it has to be restarted
+ /// (see ). Acted upon by the maintenance pass, which is
+ /// the only place allowed to restart a bot.
+ ///
+ public bool AwaitsFaultRestart { get; set; }
+
+ ///
+ public override async ValueTask StopAsync()
+ {
+ await this.StopNavigatorAsync().ConfigureAwait(false);
+ await base.StopAsync().ConfigureAwait(false);
+ }
+
+ ///
+ internal override void OnAiTickSucceeded()
+ {
+ if (this._consecutiveTickFailures > 0)
+ {
+ Interlocked.Exchange(ref this._consecutiveTickFailures, 0);
+ }
+ }
+
+ ///
+ internal override void OnAiTickFailed()
+ {
+ if (Interlocked.Increment(ref this._consecutiveTickFailures) == ConsecutiveFailuresUntilRestart)
+ {
+ // Deliberately '==', not '>=': this arms the restart exactly once, at the tick that crosses
+ // the threshold, so the log gets one line and the flag is raised once. Further failures keep
+ // incrementing the counter (which stays above the threshold) but don't re-fire; the
+ // maintenance pass consumes AwaitsFaultRestart and the restart resets the counter to 0.
+ this.Logger.LogWarning(
+ "Bot '{Name}' failed {Count} AI ticks in a row and gets restarted to heal it.",
+ this.Name,
+ ConsecutiveFailuresUntilRestart);
+ this.AwaitsFaultRestart = true;
+ }
+ }
+
+ ///
+ protected override void StartIntelligence()
+ {
+ base.StartIntelligence();
+ this._navigator = new BotNavigator(this);
+ this._navigator.Start();
+ }
+
+ ///
+ protected override async ValueTask DisposeAsyncCore()
+ {
+ await this.StopNavigatorAsync().ConfigureAwait(false);
+ await base.DisposeAsyncCore().ConfigureAwait(false);
+ }
+
+ private async ValueTask StopNavigatorAsync()
+ {
+ if (this._navigator is { } navigator)
+ {
+ this._navigator = null;
+ await navigator.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/GameLogic/Bots/BotProgression.cs b/src/GameLogic/Bots/BotProgression.cs
new file mode 100644
index 000000000..49176f34f
--- /dev/null
+++ b/src/GameLogic/Bots/BotProgression.cs
@@ -0,0 +1,433 @@
+//
+// 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;
+using MUnique.OpenMU.GameLogic.Attributes;
+
+///
+/// The shared progression rules of server-side bots: how a bot of a given class invests its stat
+/// points, and which skills it may learn. Used by the when a bot is
+/// created and by the when it levels up during play, so a
+/// freshly generated bot and one that grew to the same level in-game end up with the same build.
+///
+internal static class BotProgression
+{
+ ///
+ /// The character level at which a bot changes into its second-generation class (e.g. Dark Knight
+ /// to Blade Knight), the way a player completes the class-change quest. The quest itself boils down
+ /// to exactly this assignment (see QuestCompletionAction), so bots take the direct route.
+ ///
+ public const int ClassEvolutionLevel = 200;
+
+ ///
+ /// The character class numbers from the game's data model (CharacterClassNumber lives in the
+ /// initialization assembly which GameLogic does not reference, so the relevant values are mirrored here).
+ ///
+ private const byte DarkWizardNumber = 0;
+ private const byte SoulMasterNumber = 2;
+ private const byte GrandMasterNumber = 3;
+ private const byte DarkKnightNumber = 4;
+ private const byte BladeKnightNumber = 6;
+ private const byte BladeMasterNumber = 7;
+ private const byte FairyElfNumber = 8;
+ private const byte MuseElfNumber = 10;
+ private const byte HighElfNumber = 11;
+ private const byte MagicGladiatorNumber = 12;
+ private const byte DuelMasterNumber = 13;
+ private const byte DarkLordNumber = 16;
+ private const byte LordEmperorNumber = 17;
+ private const byte SummonerNumber = 20;
+ private const byte BloodySummonerNumber = 22;
+ private const byte DimensionMasterNumber = 23;
+ private const byte RageFighterNumber = 24;
+ private const byte FistMasterNumber = 25;
+
+ ///
+ /// The share of each invested batch that goes into vitality on reset-meta servers, until the bot's
+ /// personal is reached (out of a nominal weight total of ~100).
+ ///
+ private const int ResetMetaVitalityWeight = 5;
+
+ ///
+ /// The base classes which evolve into a second-generation class at :
+ /// Dark Wizard, Dark Knight, Fairy Elf and Summoner. The Magic Gladiator, Dark Lord and Rage Fighter
+ /// have no second generation - their next class is the level-400 master evolution, out of bot scope.
+ ///
+ private static readonly byte[] EvolvableClassNumbers = [0, 4, 8, 20];
+
+ ///
+ /// Skills of the buff type which must never enter a bot's auto-buff rotation: the summoner's
+ /// enemy debuffs (Sleep/Weakness/Innovation - the offline buff handler casts buffs on SELF, so the
+ /// bot would put itself to sleep), and Defense (18), which players get from equipping a shield
+ /// rather than learning it.
+ ///
+ private static readonly short[] ExcludedBuffSkillNumbers = [18, 219, 221, 222];
+
+ ///
+ /// Gets the class the character evolves into at , or null when the
+ /// class has no (in-scope) evolution.
+ ///
+ /// The character class.
+ public static CharacterClass? GetEvolutionTarget(CharacterClass characterClass)
+ {
+ return EvolvableClassNumbers.Contains(characterClass.Number)
+ ? characterClass.NextGenerationClass
+ : null;
+ }
+
+ ///
+ /// Gets the master class the character evolves into at the game's maximum level (the
+ /// third-generation evolution the level-400 master quests perform), or null when the current class
+ /// has none. Unlike this applies to all classes: the
+ /// second-generation classes evolve into their masters (Blade Knight -> Blade Master, ...), and
+ /// Magic Gladiator, Dark Lord and Rage Fighter - which have no second generation - evolve directly
+ /// (-> Duel Master, Lord Emperor, Fist Master). When and whether a bot takes this step is decided
+ /// by .
+ ///
+ /// The character class.
+ public static CharacterClass? GetMasterEvolutionTarget(CharacterClass characterClass)
+ {
+ return characterClass is { IsMasterClass: false, NextGenerationClass: { IsMasterClass: true } masterClass }
+ ? masterClass
+ : null;
+ }
+
+ ///
+ /// How a bot invests its stat points, per class and per bot, in one of two meta profiles chosen
+ /// by the server type (see at the call sites):
+ ///
+ /// - Reset meta (reset feature enabled) - modeled on the actual endgame characters of a
+ /// reset server: everything goes into the class's combat stats (shield and agility-based defense do
+ /// the tanking there), vitality only receives a token share until the bot's personal
+ /// is reached (enforce via capacities).
+ /// - Classic meta (no reset feature) - the builds of community stat guides for classic
+ /// servers, where point pools are small and vitality is a plain percentage of the build.
+ ///
+ /// Where a class has two established builds (knight agility/PK, gladiator warrior/mage, elf
+ /// archer/supporter), each bot picks one deterministically from its character name, so the
+ /// population is diverse but every bot keeps the same build across sessions and re-invests
+ /// consistently after each reset. The first entry is always the build's primary stat - it absorbs
+ /// rounding remainders and overflow from capped stats.
+ ///
+ /// The character class.
+ /// The character name; decides the build variant for two-build classes.
+ /// Whether the reset-server meta profile applies.
+ public static IReadOnlyList<(AttributeDefinition Stat, int Weight)> GetStatWeights(CharacterClass characterClass, string characterName, bool resetMeta)
+ {
+ // Stable across processes (string.GetHashCode is randomized per run, which would re-spec
+ // the bot on every server restart).
+ var variant = characterName.Aggregate(0, (acc, c) => acc + c) % 2;
+ var vit = Stats.BaseVitality;
+ var str = Stats.BaseStrength;
+ var agi = Stats.BaseAgility;
+ var ene = Stats.BaseEnergy;
+ var cmd = Stats.BaseLeadership;
+
+ if (resetMeta)
+ {
+ const int v = ResetMetaVitalityWeight;
+ return characterClass.Number switch
+ {
+ DarkKnightNumber or BladeKnightNumber or BladeMasterNumber => variant == 0
+ ? new[] { (str, 59), (agi, 39), (ene, 2), (vit, v) }
+ : new[] { (str, 52), (agi, 35), (ene, 13), (vit, v) },
+ DarkWizardNumber or SoulMasterNumber or GrandMasterNumber =>
+ new[] { (ene, 49), (agi, 47), (str, 4), (vit, v) },
+ FairyElfNumber or MuseElfNumber or HighElfNumber =>
+ new[] { (agi, 67), (ene, 27), (str, 6), (vit, v) },
+ MagicGladiatorNumber or DuelMasterNumber => variant == 0
+ ? new[] { (str, 57), (agi, 26), (ene, 17), (vit, v) }
+ : new[] { (ene, 66), (agi, 18), (str, 16), (vit, v) },
+ DarkLordNumber or LordEmperorNumber =>
+ new[] { (str, 54), (cmd, 23), (agi, 18), (ene, 5), (vit, v) },
+ SummonerNumber or BloodySummonerNumber or DimensionMasterNumber =>
+ new[] { (ene, 70), (agi, 18), (str, 12), (vit, v) },
+ RageFighterNumber or FistMasterNumber =>
+ new[] { (str, 64), (agi, 18), (ene, 18), (vit, v) },
+ _ when GetMainDamageStat(characterClass) == str =>
+ new[] { (str, 60), (agi, 35), (vit, v) },
+ _ => new[] { (GetMainDamageStat(characterClass), 65), (agi, 30), (vit, v) },
+ };
+ }
+
+ return characterClass.Number switch
+ {
+ DarkKnightNumber or BladeKnightNumber or BladeMasterNumber => variant == 0
+ ? new[] { (str, 62), (agi, 26), (vit, 8), (ene, 4) }
+ : new[] { (str, 50), (vit, 28), (agi, 18), (ene, 4) },
+ DarkWizardNumber or SoulMasterNumber or GrandMasterNumber =>
+ new[] { (ene, 66), (vit, 22), (agi, 8), (str, 4) },
+ FairyElfNumber or MuseElfNumber or HighElfNumber => variant == 0
+ ? new[] { (agi, 62), (vit, 23), (ene, 10), (str, 5) }
+ : new[] { (ene, 65), (vit, 22), (agi, 8), (str, 5) },
+ MagicGladiatorNumber or DuelMasterNumber => variant == 0
+ ? new[] { (str, 57), (agi, 22), (vit, 15), (ene, 6) }
+ : new[] { (ene, 58), (vit, 26), (agi, 11), (str, 5) },
+ DarkLordNumber or LordEmperorNumber =>
+ new[] { (str, 38), (cmd, 30), (vit, 22), (agi, 8), (ene, 2) },
+ SummonerNumber or BloodySummonerNumber or DimensionMasterNumber =>
+ new[] { (ene, 64), (vit, 24), (agi, 8), (str, 4) },
+ RageFighterNumber or FistMasterNumber =>
+ new[] { (str, 45), (vit, 35), (ene, 20) },
+ _ => new[] { (GetMainDamageStat(characterClass), 50), (vit, 50) },
+ };
+ }
+
+ ///
+ /// The bot's personal vitality target on reset-meta servers: how many points it invests into
+ /// vitality over its whole career (100..500, rolled deterministically from the character name,
+ /// so the population gets a natural spread from glassy to sturdy and every bot keeps its roll
+ /// across restarts and resets). The endgame players such servers breed leave vitality almost
+ /// untouched - shield and agility-based defense tank instead - so the target is intentionally low.
+ ///
+ /// The character name.
+ public static int GetVitalityTarget(string characterName)
+ {
+ var sum = characterName.Aggregate(0, (acc, c) => acc + c);
+ return 100 + ((sum * 7919) % 401);
+ }
+
+ ///
+ /// Splits the given points proportionally to the class's stat weights, returning whole-point
+ /// amounts which sum up to - unless capacities cut it short. The
+ /// optional callback limits how many points a stat may still take
+ /// (its on fun servers, the vitality target on
+ /// reset-meta servers); a filled stat drops out of the split and its share flows to the remaining
+ /// stats over subsequent rounds. When every stat is full, the rest of the points stay unassigned,
+ /// like for a maxed-out human character.
+ ///
+ /// The number of points to split.
+ /// The stat weights of the class.
+ /// Optionally resolves how many more points a stat can take; null means unlimited.
+ public static IEnumerable<(AttributeDefinition Stat, int Amount)> SplitPoints(
+ int points,
+ IReadOnlyList<(AttributeDefinition Stat, int Weight)> weights,
+ Func? capacityOf = null)
+ {
+ if (points <= 0 || weights.Count == 0)
+ {
+ yield break;
+ }
+
+ var allocated = new int[weights.Count];
+ var capacity = new long[weights.Count];
+ for (var i = 0; i < weights.Count; i++)
+ {
+ capacity[i] = Math.Max(0, capacityOf?.Invoke(weights[i].Stat) ?? long.MaxValue);
+ }
+
+ var remaining = points;
+ while (remaining > 0)
+ {
+ var activeTotalWeight = 0;
+ var firstActive = -1;
+ for (var i = 0; i < weights.Count; i++)
+ {
+ if (weights[i].Weight > 0 && allocated[i] < capacity[i])
+ {
+ activeTotalWeight += weights[i].Weight;
+ if (firstActive < 0)
+ {
+ firstActive = i;
+ }
+ }
+ }
+
+ if (activeTotalWeight <= 0)
+ {
+ break; // every stat is at its capacity - the rest stays unspent.
+ }
+
+ var assignedThisRound = 0;
+ for (var i = 0; i < weights.Count; i++)
+ {
+ if (weights[i].Weight <= 0 || allocated[i] >= capacity[i])
+ {
+ continue;
+ }
+
+ var share = (int)Math.Min((long)remaining * weights[i].Weight / activeTotalWeight, capacity[i] - allocated[i]);
+ allocated[i] += share;
+ assignedThisRound += share;
+ }
+
+ if (assignedThisRound == 0)
+ {
+ // Rounding tail (fewer points left than active stats): the primary stat takes it.
+ var tail = (int)Math.Min(remaining, capacity[firstActive] - allocated[firstActive]);
+ allocated[firstActive] += tail;
+ assignedThisRound = tail;
+ if (assignedThisRound == 0)
+ {
+ break;
+ }
+ }
+
+ remaining -= assignedThisRound;
+ }
+
+ for (var i = 0; i < weights.Count; i++)
+ {
+ if (allocated[i] > 0)
+ {
+ yield return (weights[i].Stat, allocated[i]);
+ }
+ }
+ }
+
+ ///
+ /// Determines whether the skill is one a bot may learn: an actual attack skill, or a self/party
+ /// buff or heal with a magic effect (which the offline buff/heal handlers know how to cast).
+ /// Passive boosts, event skills, enemy debuffs and utility skills are left out.
+ ///
+ /// The skill to check.
+ public static bool IsBotLearnableSkill(Skill skill)
+ {
+ if (skill.MasterDefinition is not null)
+ {
+ // Master skills are never learned for free - they cost the master points earned per master
+ // level and go through the regular action (see BotMasterHandler), like for a human player.
+ return false;
+ }
+
+ if (skill.AttackDamage > 0
+ && skill.SkillType is SkillType.DirectHit
+ or SkillType.AreaSkillAutomaticHits
+ or SkillType.AreaSkillExplicitHits
+ or SkillType.AreaSkillExplicitTarget)
+ {
+ return true;
+ }
+
+ return skill.SkillType is SkillType.Buff or SkillType.Regeneration
+ && skill.MagicEffectDef is not null
+ && !ExcludedBuffSkillNumbers.Contains(skill.Number);
+ }
+
+ ///
+ /// Determines whether the character meets the skill's learn requirements (the same ones the game
+ /// enforces when casting, e.g. total energy for wizard spells or character level for knight skills).
+ /// resolves an attribute's current value; returning null means
+ /// the attribute is unknown in the caller's context, which conservatively fails the requirement.
+ ///
+ /// The skill whose requirements are checked.
+ /// Resolves an attribute's current value; null means the attribute is unknown.
+ public static bool MeetsRequirements(Skill skill, Func getAttributeValue)
+ {
+ foreach (var requirement in skill.Requirements)
+ {
+ if (requirement.Attribute is not { } attribute)
+ {
+ continue;
+ }
+
+ if (getAttributeValue(attribute) is not { } value || value < requirement.MinimumValue)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Maps a "total" attribute (used by skill requirements) to the base stat a generated character
+ /// actually has, so requirements can be evaluated before the character was ever composed at runtime.
+ /// Returns null for attributes that have no base-stat counterpart.
+ ///
+ /// The "total" attribute to map.
+ public static AttributeDefinition? TotalToBaseStat(AttributeDefinition attribute)
+ {
+ if (attribute == Stats.TotalEnergy)
+ {
+ return Stats.BaseEnergy;
+ }
+
+ if (attribute == Stats.TotalStrength)
+ {
+ return Stats.BaseStrength;
+ }
+
+ if (attribute == Stats.TotalAgility)
+ {
+ return Stats.BaseAgility;
+ }
+
+ if (attribute == Stats.TotalVitality)
+ {
+ return Stats.BaseVitality;
+ }
+
+ if (attribute == Stats.TotalLeadership)
+ {
+ return Stats.BaseLeadership;
+ }
+
+ if (attribute == Stats.Level)
+ {
+ return Stats.Level;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Determines whether a weapon of the given item group fits the fighting style of this bot's BUILD:
+ /// archers use bows, casters staves, everyone else melee weapons. The build decides, not just the
+ /// class - a Magic Gladiator specced into energy (see the variants in )
+ /// is a caster and must get a staff, while its strength-specced sibling wants a blade; deciding by
+ /// the class's base attributes alone handed both of them swords. Classes whose base attributes make
+ /// them archers (the elves) keep their bow in every build - it is the only weapon they can wield.
+ /// Used both for the starter gear () and for later upgrades
+ /// (), so an elf never swaps its bow for a random axe it happens to
+ /// be qualified for (which would also displace its arrows).
+ ///
+ /// The character class.
+ /// The character name; decides the build variant, see .
+ /// Whether the reset-server meta profile applies.
+ /// The item group of the weapon.
+ public static bool IsPreferredWeaponGroup(CharacterClass characterClass, string characterName, bool resetMeta, byte itemGroup)
+ {
+ const byte maxMeleeGroup = 3;
+ const byte bowGroup = 4;
+ const byte staffGroup = 5;
+
+ float ClassStat(AttributeDefinition attribute)
+ => characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == attribute)?.BaseValue ?? 0f;
+
+ var strength = ClassStat(Stats.BaseStrength);
+ var agility = ClassStat(Stats.BaseAgility);
+ var energy = ClassStat(Stats.BaseEnergy);
+
+ if (agility > strength && agility > energy)
+ {
+ return itemGroup == bowGroup;
+ }
+
+ // The build's primary stat (the first weight, see GetStatWeights) tells a caster from a fighter;
+ // the class fallback covers classes without an energy build of their own.
+ var primaryStat = GetStatWeights(characterClass, characterName, resetMeta)[0].Stat;
+ if (primaryStat == Stats.BaseEnergy || energy > strength)
+ {
+ return itemGroup == staffGroup;
+ }
+
+ return itemGroup <= maxMeleeGroup;
+ }
+
+ private static AttributeDefinition GetMainDamageStat(CharacterClass characterClass)
+ {
+ return characterClass.StatAttributes
+ .Where(a => a.Attribute == Stats.BaseStrength
+ || a.Attribute == Stats.BaseAgility
+ || a.Attribute == Stats.BaseEnergy
+ || a.Attribute == Stats.BaseLeadership)
+ .OrderByDescending(a => a.BaseValue)
+ .Select(a => a.Attribute!)
+ .FirstOrDefault() ?? Stats.BaseStrength;
+ }
+}
diff --git a/src/GameLogic/Bots/BotPvpRules.cs b/src/GameLogic/Bots/BotPvpRules.cs
new file mode 100644
index 000000000..8d4629326
--- /dev/null
+++ b/src/GameLogic/Bots/BotPvpRules.cs
@@ -0,0 +1,69 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+///
+/// The single source of truth for when a server-side bot may attack a player.
+///
+///
+/// Invariant: a bot must never escalate its own . A bot which turns outlaw
+/// is a broken toy - it can be killed penalty-free forever, loses the warp command, and visibly
+/// marks itself as a misbehaving AI. The game escalates the killer's hero state (see
+/// Player.AfterKilledPlayerAsync) unless the victim is already an outlaw or the kill happened
+/// in active self-defense (duels and rival-guild wars are exempt as well, but bots have neither),
+/// so those two cases are exactly what this rule allows.
+/// The bot's grudge memory (, ~5 minutes, and the
+/// revenge march after a death) is deliberately longer than the game's self-defense window: the
+/// grudge only decides WHOM the bot prioritizes and where it walks; whether it may actually strike
+/// is decided here, per attack, against the game's own rules.
+///
+public static class BotPvpRules
+{
+ ///
+ /// How much of the self-defense window must still remain for an attack to count as safe. The
+ /// legality check runs when the attack is issued, but the kill (and with it the game's own
+ /// self-defense evaluation) can land moments later - without a margin, a final blow right at
+ /// the window's edge would escalate the bot's hero state after all. While the player keeps
+ /// attacking, every damaging hit renews the window, so the margin never interrupts an ongoing fight.
+ ///
+ private static readonly TimeSpan SelfDefenseSafetyMargin = TimeSpan.FromSeconds(3);
+
+ ///
+ /// Determines whether the bot may legally attack the given player, i.e. without any risk of
+ /// escalating the bot's own .
+ ///
+ /// The bot (or offline player) which wants to attack.
+ /// The player it wants to attack.
+ /// true if attacking is free of PK consequences; otherwise, false.
+ public static bool IsLegalPvpTarget(Player bot, Player target)
+ {
+ // A running mini game with free player killing (Chaos Castle): every fellow participant
+ // is fair game - such kills never escalate the hero state (see Player.OnDeathAsync), the
+ // game's self-defense bookkeeping doesn't even track them. Gated on the running state, so
+ // bots don't swing at players during the countdown before the event starts.
+ if (!ReferenceEquals(bot, target)
+ && bot.CurrentMiniGame is { AllowPlayerKilling: true, IsEventRunning: true } miniGame
+ && ReferenceEquals(target.CurrentMiniGame, miniGame))
+ {
+ return true;
+ }
+
+ // Outlaws are fair game for everyone - killing them never escalates the killer's state.
+ if (target.SelectedCharacter?.State >= HeroState.PlayerKiller1stStage)
+ {
+ return true;
+ }
+
+ // Active self-defense: the target attacked this bot recently (SelfDefenseState is keyed
+ // (attacker, defender) and renewed on every damaging hit by the SelfDefensePlugIn).
+ if (bot.GameContext.SelfDefenseState.TryGetValue((target, bot), out var timeout)
+ && timeout > DateTime.UtcNow.Add(SelfDefenseSafetyMargin))
+ {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/GameLogic/Bots/BotResetHandler.cs b/src/GameLogic/Bots/BotResetHandler.cs
new file mode 100644
index 000000000..915e22cb9
--- /dev/null
+++ b/src/GameLogic/Bots/BotResetHandler.cs
@@ -0,0 +1,218 @@
+//
+// 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.Attributes;
+using MUnique.OpenMU.GameLogic.Offline;
+using MUnique.OpenMU.GameLogic.PlugIns;
+using MUnique.OpenMU.GameLogic.Resets;
+
+///
+/// Performs character resets for bots on servers where the is enabled,
+/// and provides the reset-aware effective level used by the bot logic. Everything is driven by the
+/// server's actual (and ), so
+/// bots follow the same reset rules as the human players of that particular server; when the feature
+/// is disabled, none of this changes bot behavior at all.
+///
+///
+/// The reset itself mirrors the effect of , with two deliberate
+/// differences for the connection-less bot ghosts: the costs (zen, reset items) are skipped by default,
+/// because bots don't take part in the economy the costs are balanced for (see
+/// ), and the
+/// step is replaced by continuing in place - a bot has no client to log out.
+///
+internal static class BotResetHandler
+{
+ ///
+ /// Gets the server's reset configuration, or null when the reset feature is not enabled.
+ ///
+ /// The game context.
+ /// The reset configuration, or null.
+ public static ResetConfiguration? GetResetConfiguration(IGameContext gameContext)
+ => gameContext.FeaturePlugIns.GetPlugIn()?.Configuration;
+
+ ///
+ /// Gets the player's effective level for bot decisions: on a reset server a freshly reset
+ /// character is back at the configured but
+ /// fights with the accumulated power of all its resets, so the
+ /// plain level would misjudge it everywhere (target safety, map choice, party matching). Each
+ /// reset counts as the level span it took ().
+ /// Master levels count on top (like the game's own total level), so an evolved master keeps
+ /// being judged stronger than a plain level-capped character.
+ /// Without the reset feature this is the character level plus the master level.
+ ///
+ /// The player.
+ /// The effective level.
+ public static int GetEffectiveLevel(Player player)
+ {
+ var level = (int)(player.Attributes?[Stats.Level] ?? 1)
+ + (int)(player.Attributes?[Stats.MasterLevel] ?? 0f);
+ if (GetResetConfiguration(player.GameContext) is not { } configuration)
+ {
+ return level;
+ }
+
+ var resets = (int)(player.Attributes?[Stats.Resets] ?? 0f);
+ return (resets * Math.Max(0, configuration.RequiredLevel)) + level;
+ }
+
+ ///
+ /// Determines whether the bot is currently eligible for a reset: the reset feature is enabled,
+ /// the required level is reached and the reset limit (if any) is not yet exhausted.
+ ///
+ /// The bot player.
+ /// The reset configuration.
+ /// True, if the bot can reset now.
+ public static bool IsResetDue(Player player, ResetConfiguration configuration)
+ {
+ if (player.Attributes is not { } attributes)
+ {
+ return false;
+ }
+
+ if (player.Level < configuration.RequiredLevel)
+ {
+ return false;
+ }
+
+ var nextResetCount = (int)attributes[Stats.Resets] + 1;
+ return configuration.ResetLimit is not > 0 || nextResetCount <= configuration.ResetLimit;
+ }
+
+ ///
+ /// Resets the bot character, mirroring the effect of : the reset
+ /// count goes up, the level drops to , the stats and
+ /// level-up points follow the server's configuration, and with
+ /// the bot warps back to its class home town. Afterwards the regular level-up progression is fired,
+ /// so the bot immediately re-invests the granted point pool according to its class build - until that
+ /// runs, the damage-based target safety keeps the temporarily weak bot away from monsters it can no
+ /// longer handle.
+ ///
+ /// The bot player.
+ /// The reset configuration.
+ /// Whether the configured costs (zen, reset items) are consumed like for a human player.
+ /// True, if the reset was performed.
+ public static async ValueTask TryResetAsync(OfflinePlayer player, ResetConfiguration configuration, bool payCosts)
+ {
+ if (player.Attributes is not { } attributes
+ || player.SelectedCharacter is not { CharacterClass: not null } character
+ || !IsResetDue(player, configuration))
+ {
+ return false;
+ }
+
+ var resetProgression = ResetProgressionCalculator.Calculate(
+ (int)attributes[Stats.Resets],
+ (int)attributes[Stats.PointsPerReset],
+ configuration);
+
+ if (payCosts && !await TryConsumeCostsAsync(player, configuration, resetProgression).ConfigureAwait(false))
+ {
+ return false;
+ }
+
+ attributes[Stats.Resets] = resetProgression.NextResetCount;
+ attributes[Stats.Level] = configuration.LevelAfterReset;
+ character.Experience = 0;
+
+ if (configuration.ResetStats)
+ {
+ character.CharacterClass!.StatAttributes
+ .Where(s => s.IncreasableByPlayer)
+ .ForEach(s => attributes[s.Attribute] = s.BaseValue);
+ }
+
+ if (configuration.ReplacePointsPerReset)
+ {
+ character.LevelUpPoints = resetProgression.TotalPointsAfterReset;
+ }
+ else
+ {
+ character.LevelUpPoints += resetProgression.PointsForReset;
+ }
+
+ player.Logger.LogInformation(
+ "Bot '{Name}' performed reset {ResetCount} and got {Points} points to invest.",
+ player.Name,
+ resetProgression.NextResetCount,
+ character.LevelUpPoints);
+
+ if (configuration.MoveHome)
+ {
+ await MoveHomeAsync(player).ConfigureAwait(false);
+ }
+
+ // No LogOut for the connection-less ghost - instead re-run the level-up progression, which
+ // invests the whole granted point pool and re-checks the learnable skills (queued into the
+ // bot's AI tick by BotSkillProgressionPlugIn, exactly like for an earned level-up).
+ player.GameContext.PlugInManager.GetPlugInPoint()?.CharacterLeveledUp(player);
+
+ try
+ {
+ // Persist right away instead of waiting for the periodic save - losing a whole performed
+ // reset to a crash within that window would hurt far more than ordinary hunting progress.
+ await player.SaveProgressAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after its reset; the periodic save will retry.", player.Name);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Consumes the configured reset costs like does for a human
+ /// player: the required zen and the required amount of the configured reset item. Only used when
+ /// is enabled.
+ ///
+ private static async ValueTask TryConsumeCostsAsync(OfflinePlayer player, ResetConfiguration configuration, ResetProgression resetProgression)
+ {
+ IList- requiredItems = [];
+ if (resetProgression.RequiredItemAmount > 0 && configuration.RequiredResetItem is { } requiredDefinition)
+ {
+ requiredItems = player.Inventory?.Items
+ .Where(item => item.Definition is { } definition
+ && definition.Group == requiredDefinition.Group
+ && definition.Number == requiredDefinition.Number)
+ .Take(resetProgression.RequiredItemAmount)
+ .ToList() ?? [];
+ if (requiredItems.Count < resetProgression.RequiredItemAmount)
+ {
+ return false;
+ }
+ }
+
+ if (player.Money < resetProgression.RequiredZen
+ || (resetProgression.RequiredZen > 0 && !player.TryRemoveMoney(resetProgression.RequiredZen)))
+ {
+ return false;
+ }
+
+ foreach (var item in requiredItems)
+ {
+ await player.DestroyInventoryItemAsync(item).ConfigureAwait(false);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Warps the bot to a spawn gate of its class home map, the live-ghost equivalent of the
+ /// position rewrite performs before logging a player out.
+ ///
+ private static async ValueTask MoveHomeAsync(OfflinePlayer player)
+ {
+ var homeMapDefinition = player.SelectedCharacter?.CharacterClass?.HomeMap;
+ if (homeMapDefinition is null
+ || await player.GameContext.GetMapAsync((ushort)homeMapDefinition.Number).ConfigureAwait(false) is not { } homeMap
+ || homeMap.Definition.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is not { } spawnGate)
+ {
+ return;
+ }
+
+ await player.WarpToAsync(spawnGate).ConfigureAwait(false);
+ }
+}
diff --git a/src/GameLogic/Bots/BotRevengePlugIn.cs b/src/GameLogic/Bots/BotRevengePlugIn.cs
new file mode 100644
index 000000000..de1ef77e0
--- /dev/null
+++ b/src/GameLogic/Bots/BotRevengePlugIn.cs
@@ -0,0 +1,40 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.GameLogic.Offline;
+using MUnique.OpenMU.GameLogic.PlugIns;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Notes when a (human) player kills a server-side bot, so the bot can march back to the place of
+/// its death after respawning and take revenge on the killer. A bot which shrugs off being killed
+/// and calmly heads for the next hunting ground is an obvious bot giveaway - a real player comes
+/// back angry. The return march happens in the ; the counter-attack in
+/// the offline , whose re-armed aggressor memory keeps the killer
+/// prioritized - struck only once the game's own rules make it legal (see ).
+///
+[PlugIn]
+[Display(Name = "Bot revenge", Description = "Makes server-side bots return to their death site and take revenge on the player who killed them.")]
+[Guid("29B871B0-FBCF-44D4-A677-8A9832AAC193")]
+public class BotRevengePlugIn : IAttackableGotKilledPlugIn
+{
+ ///
+ public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer)
+ {
+ if (killed is OfflinePlayer bot
+ && bot.Account?.IsBot == true
+ && bot.CurrentMiniGame is null // a death in an event (Chaos Castle) is part of the game, not a wrong to avenge
+ && killer is Player killerPlayer
+ && killerPlayer is not OfflinePlayer
+ && !ReferenceEquals(killerPlayer, killed))
+ {
+ bot.RegisterDeathByPlayer(killerPlayer);
+ }
+
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/src/GameLogic/Bots/BotSelfDefensePlugIn.cs b/src/GameLogic/Bots/BotSelfDefensePlugIn.cs
new file mode 100644
index 000000000..852eee707
--- /dev/null
+++ b/src/GameLogic/Bots/BotSelfDefensePlugIn.cs
@@ -0,0 +1,36 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.GameLogic.Offline;
+using MUnique.OpenMU.GameLogic.PlugIns;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Notes when a (human) player attacks a server-side bot, so the bot's combat AI can defend itself.
+/// Without this, a bot placidly keeps farming monsters while a player kills it - the most obviously
+/// bot-like behavior an observer can trigger. The actual counter-attack happens in the offline
+/// , which prioritizes a recent aggressor over its monster targets.
+///
+[PlugIn]
+[Display(Name = "Bot self defense", Description = "Makes server-side bots fight back when a player attacks them.")]
+[Guid("7E2B9C41-5A8D-4F36-B190-3D6E84C7F215")]
+public class BotSelfDefensePlugIn : IAttackableGotHitPlugIn
+{
+ ///
+ public void AttackableGotHit(IAttackable attackable, IAttacker attacker, HitInfo hitInfo)
+ {
+ if (attackable is OfflinePlayer bot
+ && bot.Account?.IsBot == true
+ && bot.CurrentMiniGame is null // event fights (Chaos Castle) leave no grudge outside
+ && attacker is Player aggressor
+ && aggressor is not OfflinePlayer
+ && !ReferenceEquals(aggressor, attackable))
+ {
+ bot.RegisterAggressor(aggressor);
+ }
+ }
+}
diff --git a/src/GameLogic/Bots/BotServerPartition.cs b/src/GameLogic/Bots/BotServerPartition.cs
new file mode 100644
index 000000000..8ec1218c9
--- /dev/null
+++ b/src/GameLogic/Bots/BotServerPartition.cs
@@ -0,0 +1,186 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using Microsoft.Extensions.Logging;
+using MUnique.OpenMU.DataModel.Configuration;
+
+///
+/// Splits the bot population over the game servers of the deployment, so a server does not animate the
+/// whole population by itself: bots count towards the player count of their server, and a server which
+/// is full turns real clients away - the bots would lock the players out of the game.
+///
+/// Which accounts a server animates is a pure function of the account index and the SET of configured
+/// game servers, so every server computes the same answer without asking the others - no coordination,
+/// no shared state, and it holds in a deployment where each game server is its own process. The share
+/// of a server is proportional to its capacity, and only a part of that capacity
+/// () is handed to the bots: the rest stays reserved
+/// for real players, who must never be denied a slot by a bot.
+///
+///
+/// The split is computed when the server starts. Adding a game server to a running deployment therefore
+/// takes a restart before the population spreads onto it; that is deliberate. Moving the ownership of a
+/// bot between two RUNNING servers would mean one server animating an account the other one is still
+/// animating - the very cross-context situation which corrupts a character.
+///
+///
+internal sealed class BotServerPartition
+{
+ private BotServerPartition(int firstAccount, int accountCount, bool isGenerator)
+ {
+ this.FirstAccount = firstAccount;
+ this.AccountCount = accountCount;
+ this.IsGenerator = isGenerator;
+ }
+
+ ///
+ /// Gets the one-based index of the first bot account this server animates.
+ ///
+ public int FirstAccount { get; }
+
+ ///
+ /// Gets the number of bot accounts this server animates.
+ ///
+ public int AccountCount { get; }
+
+ ///
+ /// Gets a value indicating whether this server generates the bot population. Exactly one server
+ /// does it (the one which animates the first account), so the generation of the accounts - and of
+ /// their unique character names - never runs twice at the same time. The other servers simply find
+ /// their accounts once they exist; until then, their spawns are retried by the maintenance pass.
+ ///
+ public bool IsGenerator { get; }
+
+ ///
+ /// Determines the share of the bot population which the given game server animates.
+ ///
+ /// The context of the game server which asks.
+ /// The bot configuration.
+ /// The logger.
+ /// The share of this server.
+ public static async ValueTask CreateAsync(IGameContext gameContext, BotConfiguration configuration, ILogger logger)
+ {
+ var requestedAccounts = Math.Max(configuration.NumberOfAccounts, 0);
+ var charactersPerAccount = configuration.GetEffectiveCharactersPerAccount();
+ var capacities = await GetAccountCapacitiesAsync(gameContext, configuration, charactersPerAccount, logger).ConfigureAwait(false);
+ if (gameContext is not IGameServerContext serverContext || capacities.Count == 0)
+ {
+ // A deployment we cannot split (no server definitions readable, or a context which is not a
+ // game server, e.g. in tests): behave exactly like before - this server animates everything.
+ return new BotServerPartition(1, requestedAccounts, true);
+ }
+
+ var (partition, assignedAccounts) = Split(capacities, serverContext.Id, requestedAccounts);
+ if (assignedAccounts < requestedAccounts)
+ {
+ logger.LogWarning(
+ "The bot population does not fit: {Requested} account(s) configured, but only {Fitting} fit into {Percent}% of the game servers' capacity. {Dropped} account(s) stay offline - raise the servers' maximum player count, the bot capacity share, or lower the number of accounts.",
+ requestedAccounts,
+ assignedAccounts,
+ configuration.GetEffectiveBotCapacityPercent(),
+ requestedAccounts - assignedAccounts);
+ }
+
+ logger.LogInformation(
+ "This game server ({ServerId}) animates {Count} bot account(s) ({First}..{Last}) of {Requested}.",
+ serverContext.Id,
+ partition.AccountCount,
+ partition.AccountCount == 0 ? 0 : partition.FirstAccount,
+ partition.AccountCount == 0 ? 0 : partition.FirstAccount + partition.AccountCount - 1,
+ requestedAccounts);
+
+ return partition;
+ }
+
+ ///
+ /// Determines whether this server animates the bot account with the given one-based index.
+ ///
+ /// The one-based bot account index.
+ public bool Owns(int accountIndex)
+ => accountIndex >= this.FirstAccount && accountIndex < this.FirstAccount + this.AccountCount;
+
+ ///
+ /// Hands the accounts to the servers, each getting a share PROPORTIONAL to its capacity: the pure
+ /// decision behind . Every server runs it over the same list and gets the
+ /// same answer, which is what makes the split need no coordination at all.
+ ///
+ /// Proportional, not first-come: filling one server to the brim before using the next would leave the
+ /// added server empty until the first one overflows - and a player on it would meet nobody. The bots
+ /// are there to populate the world, so they spread over the servers the players can choose from.
+ ///
+ ///
+ /// How many accounts each game server may animate, ordered by server id.
+ /// The id of the server which asks.
+ /// The configured number of bot accounts.
+ /// The share of the asking server, and how many accounts fit into the deployment at all.
+ internal static (BotServerPartition Partition, int AssignedAccounts) Split(
+ IEnumerable<(byte ServerId, int Capacity)> capacities,
+ byte serverId,
+ int requestedAccounts)
+ {
+ var servers = capacities.Where(c => c.Capacity > 0).ToList();
+ var totalCapacity = servers.Sum(server => (long)server.Capacity);
+ if (totalCapacity == 0 || requestedAccounts <= 0)
+ {
+ return (new BotServerPartition(1, 0, false), 0);
+ }
+
+ // What does not fit into the servers' share stays offline; those accounts wake up as soon as the
+ // deployment offers the room (another game server, a higher player limit or bot capacity share).
+ var assignedAccounts = (int)Math.Min(requestedAccounts, totalCapacity);
+
+ var partition = new BotServerPartition(1, 0, false);
+ long capacitySoFar = 0;
+ var accountsSoFar = 0;
+ foreach (var (currentServer, capacity) in servers)
+ {
+ capacitySoFar += capacity;
+
+ // Walk the cumulative capacity, so the rounding of one server's share is corrected by the
+ // next one instead of adding up: the shares always sum up to the assigned accounts exactly.
+ var accountsUpToHere = (int)(assignedAccounts * capacitySoFar / totalCapacity);
+ var share = accountsUpToHere - accountsSoFar;
+ if (currentServer == serverId && share > 0)
+ {
+ // The server which owns the first account generates the population.
+ partition = new BotServerPartition(accountsSoFar + 1, share, accountsSoFar == 0);
+ }
+
+ accountsSoFar = accountsUpToHere;
+ }
+
+ return (partition, assignedAccounts);
+ }
+
+ ///
+ /// Reads how many bot ACCOUNTS each configured game server may animate: its maximum player count,
+ /// reduced to the bots' share of it, divided by the characters an account animates at once. The
+ /// servers are ordered by their id, so every server walks the same list in the same order.
+ ///
+ private static async ValueTask
> GetAccountCapacitiesAsync(
+ IGameContext gameContext,
+ BotConfiguration configuration,
+ int charactersPerAccount,
+ ILogger logger)
+ {
+ try
+ {
+ using var context = gameContext.PersistenceContextProvider.CreateNewConfigurationContext();
+ var definitions = await context.GetAsync().ConfigureAwait(false);
+ var capacityPercent = configuration.GetEffectiveBotCapacityPercent();
+ return definitions
+ .OrderBy(definition => definition.ServerID)
+ .Select(definition => (
+ definition.ServerID,
+ Capacity: (definition.ServerConfiguration?.MaximumPlayers ?? 0) * capacityPercent / 100 / charactersPerAccount))
+ .ToList();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Could not read the game server definitions; this server animates the whole bot population.");
+ return [];
+ }
+ }
+}
diff --git a/src/GameLogic/Bots/BotShoppingHandler.cs b/src/GameLogic/Bots/BotShoppingHandler.cs
new file mode 100644
index 000000000..86bf0f05a
--- /dev/null
+++ b/src/GameLogic/Bots/BotShoppingHandler.cs
@@ -0,0 +1,245 @@
+//
+// 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.NPC;
+using MUnique.OpenMU.GameLogic.Offline;
+using MUnique.OpenMU.GameLogic.PlayerActions;
+using MUnique.OpenMU.GameLogic.PlayerActions.Items;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// Lets a bot trade with town merchants like a real player: when the backpack silts up or the potions
+/// run low, the bot visits a merchant, sells its junk loot for Zen and buys potion refills with it -
+/// closing the economic loop (materializing supplies out of thin air stays only as an emergency
+/// fallback, see the low threshold in ). The trade uses the regular player
+/// actions (, , ),
+/// and while the dialog is open the player state pauses the combat AI - the bot visibly "shops".
+///
+internal static class BotShoppingHandler
+{
+ /// Start a shopping trip when fewer free backpack slots than this remain.
+ private const int FreeSlotPressure = 20;
+
+ /// Start a shopping trip when a potion kind has fewer charges than this.
+ private const int PotionLowThreshold = 40;
+
+ ///
+ /// Stop buying refills once this many charges are stocked. Merchants sell potions by the piece,
+ /// so this target keeps a trip affordable (roughly a bot's income between two trips) while lasting
+ /// a good while of hunting.
+ ///
+ private const int PotionTargetCharges = 60;
+
+ /// Maximum purchases per potion kind per trip.
+ private const int MaxPurchasesPerKind = 20;
+
+ /// Zen the bot keeps in reserve - it stops buying rather than spend its last coin.
+ private const int MinZenReserve = 10000;
+
+ private static readonly TalkNpcAction TalkAction = new();
+ private static readonly SellItemToNpcAction SellAction = new();
+ private static readonly BuyNpcItemAction BuyAction = new();
+ private static readonly CloseNpcDialogAction CloseAction = new();
+
+ private static readonly ItemIdentifier[] Valuables =
+ [
+ ItemConstants.JewelOfChaos,
+ ItemConstants.JewelOfBless,
+ ItemConstants.JewelOfSoul,
+ ItemConstants.JewelOfLife,
+ ItemConstants.JewelOfCreation,
+ ItemConstants.JewelOfGuardian,
+ ItemConstants.Gemstone,
+ ItemConstants.JewelOfHarmony,
+ ItemConstants.LowerRefineStone,
+ ItemConstants.HigherRefineStone,
+ ];
+
+ ///
+ /// Determines whether the bot should go shopping: the backpack is filling up with sellable junk,
+ /// or a potion stack is running low (and there is Zen to restock with).
+ ///
+ /// The bot player.
+ public static bool NeedsShopping(OfflinePlayer player)
+ {
+ if (player.Inventory is not { } inventory)
+ {
+ return false;
+ }
+
+ var freeSlots = inventory.FreeSlots.Count();
+ if (freeSlots < FreeSlotPressure && inventory.Items.Any(i => IsSellableJunk(player, i)))
+ {
+ return true;
+ }
+
+ if (player.Money > 5000 && GetLowPotionKinds(player).Any())
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Finds the position of a merchant NPC on the map, preferring one which sells potions. The
+ /// search runs over the map's LIVE objects, not the spawn configuration: wandering merchants
+ /// exist in the configuration but are only spawned now and then (their spawn trigger is not
+ /// automatic) - a bot walking to a configured but unspawned merchant would wait at an empty
+ /// spot and give up its trip, forever.
+ ///
+ /// The game map.
+ public static Point? FindMerchantPosition(GameMap map)
+ {
+ // Covers the whole 256x256 map from its center.
+ var merchants = map.GetNpcsInRange(new Point(128, 128), 256)
+ .Where(n => n.Definition is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore.Items.Count: > 0 })
+ .ToList();
+ var best = merchants.FirstOrDefault(m => SellsPotions(m.Definition.MerchantStore!)) ?? merchants.FirstOrDefault();
+ return best?.Position;
+ }
+
+ ///
+ /// Performs the actual trade with the merchant standing near the given position: opens the dialog,
+ /// sells the junk loot, buys potion refills and closes the dialog again.
+ ///
+ /// The bot player.
+ /// The game map.
+ /// The position of the merchant.
+ /// True, if a merchant was found and the trade ran; false if no merchant is there.
+ public static async ValueTask TryTradeAsync(OfflinePlayer player, GameMap map, Point merchantPosition)
+ {
+ var merchant = map.GetNpcsInRange(merchantPosition, 4)
+ .FirstOrDefault(n => n.Definition is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore.Items.Count: > 0 });
+ if (merchant is null || player.Inventory is not { } inventory)
+ {
+ // Not silent: exactly this case hid the wandering-merchant bug (a configured but
+ // unspawned merchant) for a long time.
+ player.Logger.LogInformation("Bot '{Name}' found no merchant near {Position} and gives up the trip.", player.Name, merchantPosition);
+ return false;
+ }
+
+ await TalkAction.TalkToNpcAsync(player, merchant).ConfigureAwait(false);
+ if (player.OpenedNpc is null)
+ {
+ player.Logger.LogInformation("Bot '{Name}' could not open the dialog of '{Merchant}'.", player.Name, merchant.Definition.Designation);
+ return false;
+ }
+
+ try
+ {
+ var soldCount = 0;
+ foreach (var junk in inventory.Items.Where(i => IsSellableJunk(player, i)).ToList())
+ {
+ await SellAction.SellItemAsync(player, junk.ItemSlot).ConfigureAwait(false);
+ soldCount++;
+ }
+
+ var boughtCount = 0;
+ var store = player.OpenedNpc.Definition.MerchantStore;
+ foreach (var potionNumber in GetLowPotionKinds(player))
+ {
+ var storeItem = store?.Items.FirstOrDefault(i => i.Definition?.Group == 14 && i.Definition.Number == potionNumber);
+ if (storeItem is null)
+ {
+ continue;
+ }
+
+ for (var i = 0; i < MaxPurchasesPerKind
+ && GetPotionCharges(player, potionNumber) < PotionTargetCharges
+ && player.Money > MinZenReserve; i++)
+ {
+ var moneyBefore = player.Money;
+ await BuyAction.BuyItemAsync(player, storeItem.ItemSlot).ConfigureAwait(false);
+ if (player.Money >= moneyBefore)
+ {
+ break; // purchase failed (no money / no space)
+ }
+
+ boughtCount++;
+ }
+ }
+
+ // Logged even for a 0/0 visit: an audit must be able to tell "went and had nothing to
+ // do" from a silently failed trip.
+ player.Logger.LogInformation(
+ "Bot '{Name}' traded with '{Merchant}': sold {Sold} item(s), bought {Bought} potion stack(s), {Money} zen left.",
+ player.Name,
+ merchant.Definition.Designation,
+ soldCount,
+ boughtCount,
+ player.Money);
+ }
+ finally
+ {
+ await CloseAction.CloseNpcDialogAsync(player).ConfigureAwait(false);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Gets the potion kinds (item numbers in group 14) whose stack is running low.
+ ///
+ private static IEnumerable GetLowPotionKinds(Player player)
+ {
+ if (GetPotionCharges(player, 3) < PotionLowThreshold)
+ {
+ yield return 3; // Large Healing Potion
+ }
+
+ if (GetPotionCharges(player, 6) < PotionLowThreshold)
+ {
+ yield return 6; // Large Mana Potion
+ }
+ }
+
+ private static int GetPotionCharges(Player player, byte potionNumber)
+ {
+ return (int)(player.Inventory?.Items
+ .Where(i => i.Definition?.Group == 14 && i.Definition.Number == potionNumber)
+ .Sum(i => i.Durability) ?? 0);
+ }
+
+ ///
+ /// Sellable junk: unequipped gear in the backpack - not potions/ammunition and not jewels. An
+ /// excellent or ancient piece is junk too unless it is an upgrade the bot is about to wear:
+ /// unlike a player, a bot cannot trade its treasures away, so hoarding them only silts up the
+ /// backpack until the loot pickup stops entirely.
+ ///
+ private static bool IsSellableJunk(OfflinePlayer player, Item item)
+ {
+ if (item.ItemSlot < InventoryConstants.EquippableSlotsCount
+ || item.Definition is not { } definition)
+ {
+ return false;
+ }
+
+ if (definition.Group >= 12 || definition.IsAmmunition)
+ {
+ return false; // potions, jewels, scrolls, event items etc.
+ }
+
+ if (Valuables.Contains(new ItemIdentifier(definition.Number, definition.Group)))
+ {
+ return false;
+ }
+
+ var isTreasure = item.ItemOptions.Any(o => o.ItemOption?.OptionType == DataModel.Configuration.Items.ItemOptionTypes.Excellent)
+ || item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0);
+ if (isTreasure)
+ {
+ return !BotEquipmentHandler.IsUpgradeFor(player, item);
+ }
+
+ return true;
+ }
+
+ private static bool SellsPotions(DataModel.Entities.ItemStorage store)
+ {
+ return store.Items.Any(i => i.Definition?.Group == 14 && i.Definition.Number is 3 or 6);
+ }
+}
diff --git a/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs
new file mode 100644
index 000000000..ece66d10c
--- /dev/null
+++ b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs
@@ -0,0 +1,177 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using System.Runtime.InteropServices;
+using Microsoft.Extensions.Logging;
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.PlayerActions.Character;
+using MUnique.OpenMU.GameLogic.PlugIns;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Grows a server-side bot like a real player when it levels up during play: the earned stat points are
+/// invested according to the bot's class build (see ), and any
+/// class skill whose learn requirements (total energy, leadership, character level, ...) are now met is
+/// learned - attack skills as well as the class's own buffs and heals. Skills are only ever learned for
+/// the character's own class (), using the same requirements the
+/// game enforces for human players, so a grown bot matches a freshly generated one of the same level.
+///
+[PlugIn]
+[Display(Name = "Bot skill progression", Description = "Invests level-up stat points and teaches server-side bots new class- and level-appropriate skills as they level up.")]
+[Guid("D1F4A7C2-6B3E-4A59-8E71-9C0D2F5B6A84")]
+public class BotSkillProgressionPlugIn : ICharacterLevelUpPlugIn
+{
+ private static readonly IncreaseStatsAction IncreaseStatsAction = new();
+
+ private static readonly BotSkillProgressionPlugIn CatchUp = new();
+
+ ///
+ /// Applies the progression a bot is owed but has not spent, when it enters the world. Points are
+ /// otherwise only invested on a level-up, so a character which was given points while it was not
+ /// playing - a freshly generated one, or one whose level-up handler failed - would carry them around
+ /// unspent until its next level-up and fight with the strength of a much weaker character in the
+ /// meantime. Cheap: only a bot which actually holds points is progressed.
+ ///
+ /// The bot which entered the world.
+ public static void CatchUpPendingProgress(Player player)
+ {
+ if (player.SelectedCharacter?.LevelUpPoints > 0)
+ {
+ CatchUp.CharacterLeveledUp(player);
+ }
+ }
+
+ ///
+ public void CharacterLeveledUp(Player player)
+ {
+ if (player.Account?.IsBot != true
+ || player.SelectedCharacter?.CharacterClass is not { }
+ || player.SkillList is not { })
+ {
+ return;
+ }
+
+ // Queue the progression into the bot's AI tick instead of running it right here: this hook fires
+ // from the experience/level-up path while the combat handler may be enumerating the skill list on
+ // its own timer - the tick serializes both. No own SaveChanges here - the new stats and skills
+ // are persisted by the periodic save, avoiding extra concurrency pressure.
+ if (player is Offline.OfflinePlayer offlinePlayer)
+ {
+ offlinePlayer.PendingBotActions.Enqueue(() => new ValueTask(this.ProgressAsync(offlinePlayer)));
+ }
+ else
+ {
+ _ = this.ProgressAsync(player);
+ }
+ }
+
+ private async Task ProgressAsync(Player player)
+ {
+ try
+ {
+ this.EvolveClassIfDue(player);
+ await this.SpendStatPointsAsync(player).ConfigureAwait(false);
+ await this.LearnNewSkillsAsync(player).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ player.Logger.LogError(ex, "Failed to progress bot '{Name}' after level-up.", player.Name);
+ }
+ }
+
+ ///
+ /// Changes the bot into its second-generation class (Dark Knight -> Blade Knight etc.) once it
+ /// reaches - the exact assignment the class-change
+ /// quest performs for a human player (see QuestCompletionAction); simulating the quest run
+ /// itself would be invisible to observers anyway. Skills and gear of the new class follow
+ /// automatically, because all bot progression keys off the current class's qualifications.
+ ///
+ private void EvolveClassIfDue(Player player)
+ {
+ var character = player.SelectedCharacter!;
+ if (player.Level < BotProgression.ClassEvolutionLevel
+ || BotProgression.GetEvolutionTarget(character.CharacterClass!) is not { } evolvedClass)
+ {
+ return;
+ }
+
+ character.CharacterClass = evolvedClass;
+ player.Logger.LogInformation(
+ "Bot '{Name}' evolved into {Class} at level {Level}.",
+ player.Name,
+ evolvedClass.Name,
+ player.Level);
+ }
+
+ private async ValueTask SpendStatPointsAsync(Player player)
+ {
+ var character = player.SelectedCharacter!;
+ var characterClass = character.CharacterClass!;
+ var points = character.LevelUpPoints;
+ if (points <= 0)
+ {
+ return;
+ }
+
+ var resetMeta = BotResetHandler.GetResetConfiguration(player.GameContext) is not null;
+ var weights = BotProgression.GetStatWeights(characterClass, character.Name, resetMeta);
+ var vitalityTarget = resetMeta ? BotProgression.GetVitalityTarget(character.Name) : (int?)null;
+
+ // Mirrors the capacity checks of the stat-increase action (a stat's configured maximum on fun
+ // servers), plus the bot's personal vitality target on reset-meta servers: a full stat drops
+ // out of the split, so its share flows into the rest of the build instead of getting lost.
+ long CapacityOf(AttributeDefinition stat)
+ {
+ var classBase = characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == stat);
+ var current = (long)(player.Attributes?[stat] ?? 0f);
+ var capacity = long.MaxValue;
+ if (classBase?.Attribute?.MaximumValue is { } maximumValue)
+ {
+ capacity = (long)maximumValue - current;
+ }
+
+ if (vitalityTarget is { } target && stat == Stats.BaseVitality)
+ {
+ var invested = current - (long)(classBase?.BaseValue ?? 0f);
+ capacity = Math.Min(capacity, target - invested);
+ }
+
+ return capacity;
+ }
+
+ foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights, CapacityOf))
+ {
+ if (amount > 0)
+ {
+ await IncreaseStatsAction.IncreaseStatsAsync(player, stat, (ushort)amount).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async ValueTask LearnNewSkillsAsync(Player player)
+ {
+ var characterClass = player.SelectedCharacter!.CharacterClass!;
+ var skillList = player.SkillList!;
+
+ float? GetValue(AttributeDefinition attribute) => player.Attributes?[attribute];
+
+ foreach (var skill in player.GameContext.Configuration.Skills)
+ {
+ if (!BotProgression.IsBotLearnableSkill(skill)
+ || !skill.QualifiedCharacters.Contains(characterClass)
+ || skillList.ContainsSkill((ushort)skill.Number)
+ || !BotProgression.MeetsRequirements(skill, GetValue))
+ {
+ continue;
+ }
+
+ await skillList.AddLearnedSkillAsync(skill).ConfigureAwait(false);
+ player.Logger.LogInformation("Bot '{Name}' learned '{Skill}' at level {Level}.", player.Name, skill.Name, player.Level);
+ }
+ }
+}
diff --git a/src/GameLogic/Bots/BotWingHandler.cs b/src/GameLogic/Bots/BotWingHandler.cs
new file mode 100644
index 000000000..094f731c8
--- /dev/null
+++ b/src/GameLogic/Bots/BotWingHandler.cs
@@ -0,0 +1,263 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.Offline;
+
+///
+/// Grants a bot its wings at the classic level milestones, like a player who saves up for them:
+/// at level the first pair (+0, luck, +12 option), at
+/// the second pair (+9, luck, +16 option) and at
+/// the third pair (+15, luck, +16 option). Wings don't drop from
+/// monsters, so they are created directly and put straight into the wing slot - the planner
+/// guarantees the class qualification and level requirement a regular equip would check, and the
+/// slot placement mounts the power-ups like one. The outgrown pair is destroyed - never dropped,
+/// so no player can grab a made-up wing from the ground.
+/// The wing model of the item data does the rest: which classes may wear which pair is defined by
+/// , so e.g. the third-tier wings (master classes
+/// only) are simply not offered to a bot which did not evolve yet, and the Dark Lord/Rage Fighter
+/// capes - their only pre-master wing - are re-granted as a fresh +9 cape at the second milestone.
+///
+internal static class BotWingHandler
+{
+ /// The level at which a bot gets its first tier wings (+0, luck, +12 option).
+ private const int FirstTierLevel = 180;
+
+ /// The level at which a bot gets its second tier wings (+9, luck, +16 option).
+ private const int SecondTierLevel = 280;
+
+ /// The level at which a bot gets its third tier wings (+15, luck, +16 option).
+ private const int ThirdTierLevel = 400;
+
+ /// The item level of the second tier grant; also disambiguates the tier of a cape (see ).
+ private const byte SecondTierItemLevel = 9;
+
+ /// First tier wing numbers (group 12): Wings of Elf, Heaven, Satan and Curse.
+ private static readonly (byte Group, short Number)[] FirstTierIds = { (12, 0), (12, 1), (12, 2), (12, 41) };
+
+ /// Second tier wing numbers (group 12): Wings of Spirits, Soul, Dragon, Darkness and Despair.
+ private static readonly (byte Group, short Number)[] SecondTierIds = { (12, 3), (12, 4), (12, 5), (12, 6), (12, 42) };
+
+ /// Third tier wing numbers (group 12): Wing of Storm, Eternal, Illusion, Ruin, Dimension and the Capes of Emperor/Overrule.
+ private static readonly (byte Group, short Number)[] ThirdTierIds = { (12, 36), (12, 37), (12, 38), (12, 39), (12, 40), (12, 43), (12, 50) };
+
+ ///
+ /// The Cape of Lord (13, 30, Dark Lord) and Cape of Fighter (12, 49, Rage Fighter): the only
+ /// pre-master wing of their classes, granted at the first milestone at +0 and again at the
+ /// second as a fresh +9 cape. Unlike all other wings, the Cape of Lord lives in group 13 -
+ /// group 12 number 30 is the Packed Jewel of Bless (which the wing-slot filter of
+ /// would keep out of the candidates anyway).
+ ///
+ private static readonly (byte Group, short Number)[] CapeIds = { (13, 30), (12, 49) };
+
+ ///
+ /// Checks the bot's level milestones and puts on the earned wings; called from the bot's regular
+ /// evaluation cadence, queued into the MuHelper tick because equipping mounts item power-ups.
+ ///
+ /// The bot player.
+ public static async ValueTask TryAdvanceWingsAsync(OfflinePlayer player)
+ {
+ if (PlanNextGrant(player) is not { } plan || player.Inventory is not { } inventory)
+ {
+ return;
+ }
+
+ // The outgrown pair is destroyed, not dropped - a conjured wing must never lie on the
+ // ground for a player to pick up. Clearing the slot first also keeps the grant independent
+ // of the backpack, which is usually too crammed with loot for a wing's 5x3 footprint.
+ if (inventory.GetItem(InventoryConstants.WingsSlot) is { } outgrown)
+ {
+ await player.DestroyInventoryItemAsync(outgrown).ConfigureAwait(false);
+ player.Logger.LogInformation("Bot '{Name}' discarded its outgrown wings '{Wings}'.", player.Name, outgrown);
+ }
+
+ // Directly into the wing slot: the planner already guarantees the class qualification and
+ // the level requirement, and the slot placement mounts the power-ups and broadcasts the
+ // changed appearance like a regular equip.
+ var wings = CreateWings(player, plan);
+ if (!await inventory.AddItemAsync(InventoryConstants.WingsSlot, wings).ConfigureAwait(false))
+ {
+ // Shouldn't happen - the slot was just cleared; don't leak the created item.
+ await player.PersistenceContext.DeleteAsync(wings).ConfigureAwait(false);
+ player.Logger.LogWarning("Bot '{Name}' could not equip its new wings '{Wings}'.", player.Name, wings);
+ return;
+ }
+
+ player.Logger.LogInformation("Bot '{Name}' earned its tier {Tier} wings: '{Wings}'.", player.Name, plan.Tier, wings);
+
+ try
+ {
+ // Persist right away like after using jewels - a milestone shouldn't be lost (and the
+ // outgrown pair resurrected) by a crash before the next periodic save.
+ await player.SaveProgressAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after granting wings; the periodic save will retry.", player.Name);
+ }
+ }
+
+ ///
+ /// Determines the wings the bot has earned but does not wear yet, or null when it already
+ /// wears its best earned pair (or none is due). Pure decision logic - exposed for unit tests.
+ ///
+ /// The bot player.
+ internal static (ItemDefinition Definition, byte ItemLevel, int OptionLevel, int Tier)? PlanNextGrant(Player player)
+ {
+ if (player.Inventory is not { } inventory
+ || player.SelectedCharacter?.CharacterClass is not { } characterClass
+ || player.Attributes is not { } attributes)
+ {
+ return null;
+ }
+
+ var level = (int)attributes[Stats.Level];
+ var earnedTier = level switch
+ {
+ >= ThirdTierLevel => 3,
+ >= SecondTierLevel => 2,
+ >= FirstTierLevel => 1,
+ _ => 0,
+ };
+
+ // Walk down from the earned tier to the best one the class currently qualifies for: a bot
+ // which did not evolve into its master class yet simply isn't qualified for the third tier
+ // wings and keeps its second pair until the evolution.
+ for (var tier = earnedTier; tier >= 1; tier--)
+ {
+ var candidates = player.GameContext.Configuration.Items
+ .Where(d => TierIds(tier).Contains((d.Group, d.Number))
+ && d.ItemSlot?.ItemSlots.Contains(InventoryConstants.WingsSlot) == true
+ && d.QualifiedCharacters.Contains(characterClass))
+ .ToList();
+ if (candidates.Count == 0)
+ {
+ continue;
+ }
+
+ if (inventory.GetItem(InventoryConstants.WingsSlot) is { } equipped && TierOf(equipped) >= tier)
+ {
+ // Already wearing this tier (or a better one, e.g. right after a reset while
+ // re-levelling through the lower milestones) - never downgrade.
+ return null;
+ }
+
+ var isCaster = attributes[Stats.TotalEnergy] > attributes[Stats.TotalStrength];
+ var definition = candidates.MaxBy(d => FindWingOption(d, isCaster).Score)!;
+ var (itemLevel, optionLevel) = tier switch
+ {
+ 3 => ((byte)15, 4),
+ 2 => (SecondTierItemLevel, 4),
+ _ => ((byte)0, 3),
+ };
+
+ return (definition, itemLevel, optionLevel, tier);
+ }
+
+ return null;
+ }
+
+ private static IReadOnlyCollection<(byte Group, short Number)> TierIds(int tier)
+ {
+ return tier switch
+ {
+ 3 => ThirdTierIds,
+ 2 => SecondTierIds.Concat(CapeIds).ToList(),
+ _ => FirstTierIds.Concat(CapeIds).ToList(),
+ };
+ }
+
+ ///
+ /// The tier a worn wing counts as; the capes are the first-tier grant of their classes but count
+ /// as the second one once re-granted at +9.
+ ///
+ private static int TierOf(Item wings)
+ {
+ if (wings.Definition is not { } definition)
+ {
+ return 0;
+ }
+
+ var id = (definition.Group, definition.Number);
+ if (ThirdTierIds.Contains(id))
+ {
+ return 3;
+ }
+
+ if (CapeIds.Contains(id))
+ {
+ return wings.Level >= SecondTierItemLevel ? 2 : 1;
+ }
+
+ if (SecondTierIds.Contains(id))
+ {
+ return 2;
+ }
+
+ return FirstTierIds.Contains(id) ? 1 : 0;
+ }
+
+ ///
+ /// Picks the wing's "additional" option (the +4-per-level one, )
+ /// which fits the bot's fighting style best - wizardry damage for casters, physical damage
+ /// otherwise - and scores it, so wings offering the matching damage option (the Magic Gladiator
+ /// may wear both Wings of Heaven and Satan) win the candidate selection.
+ ///
+ private static (IncreasableItemOption? Option, int Score) FindWingOption(ItemDefinition definition, bool isCaster)
+ {
+ static int ScoreOf(IncreasableItemOption option, bool isCaster)
+ {
+ var target = option.PowerUpDefinition?.TargetAttribute;
+ if (target == Stats.WizardryBaseDmg || target == Stats.CurseBaseDmg)
+ {
+ return isCaster ? 3 : 1;
+ }
+
+ if (target == Stats.PhysicalBaseDmg)
+ {
+ return isCaster ? 1 : 3;
+ }
+
+ return 0;
+ }
+
+ return definition.PossibleItemOptions
+ .SelectMany(o => o.PossibleOptions)
+ .Where(o => o.OptionType == ItemOptionTypes.Option)
+ .Select(o => ((IncreasableItemOption?)o, ScoreOf(o, isCaster)))
+ .OrderByDescending(pair => pair.Item2)
+ .FirstOrDefault();
+ }
+
+ private static Item CreateWings(OfflinePlayer player, (ItemDefinition Definition, byte ItemLevel, int OptionLevel, int Tier) plan)
+ {
+ var item = player.PersistenceContext.CreateNew- ();
+ item.Definition = plan.Definition;
+ item.Level = plan.ItemLevel;
+ item.Durability = plan.Definition.Durability;
+
+ if (plan.Definition.PossibleItemOptions
+ .SelectMany(o => o.PossibleOptions)
+ .FirstOrDefault(o => o.OptionType == ItemOptionTypes.Luck) is { } luck)
+ {
+ var luckLink = player.PersistenceContext.CreateNew();
+ luckLink.ItemOption = luck;
+ item.ItemOptions.Add(luckLink);
+ }
+
+ var isCaster = player.Attributes![Stats.TotalEnergy] > player.Attributes[Stats.TotalStrength];
+ if (FindWingOption(plan.Definition, isCaster).Option is { } option)
+ {
+ var optionLink = player.PersistenceContext.CreateNew();
+ optionLink.ItemOption = option;
+ optionLink.Level = plan.OptionLevel;
+ item.ItemOptions.Add(optionLink);
+ }
+
+ return item;
+ }
+}
diff --git a/src/GameLogic/Bots/PendingPartyInvite.cs b/src/GameLogic/Bots/PendingPartyInvite.cs
new file mode 100644
index 000000000..2ecd01c8c
--- /dev/null
+++ b/src/GameLogic/Bots/PendingPartyInvite.cs
@@ -0,0 +1,13 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Bots;
+
+///
+/// A party invitation from a player which a bot accepted, waiting for the human-like delay to pass
+/// before the party is actually formed (see ).
+///
+/// The player who invited the bot.
+/// When the bot answers the invitation.
+internal sealed record PendingPartyInvite(Player Requester, DateTime AcceptAtUtc);
diff --git a/src/GameLogic/GameMap.cs b/src/GameLogic/GameMap.cs
index 159afdcdf..ce7ccf469 100644
--- a/src/GameLogic/GameMap.cs
+++ b/src/GameLogic/GameMap.cs
@@ -114,6 +114,17 @@ public IList GetAttackablesInRange(Point point, int range)
return this._areaOfInterestManager.GetInRange(point, range).OfType().ToList();
}
+ ///
+ /// Gets all non-player characters (e.g. merchants) within the specified range of a point.
+ ///
+ /// The coordinates.
+ /// The range.
+ /// The non-player characters in range of the specified coordinate.
+ public IList GetNpcsInRange(Point point, int range)
+ {
+ return this._areaOfInterestManager.GetInRange(point, range).OfType().ToList();
+ }
+
///
/// Gets all dropped items and money within the specified range of a point.
///
diff --git a/src/GameLogic/ItemExtensions.cs b/src/GameLogic/ItemExtensions.cs
index f3658f466..d9f24aa36 100644
--- a/src/GameLogic/ItemExtensions.cs
+++ b/src/GameLogic/ItemExtensions.cs
@@ -137,6 +137,37 @@ public static bool IsShield(this Item item)
return item.Definition?.Group == ShieldItemGroup;
}
+ ///
+ /// Determines whether equipping an item of this definition into the given hand slot would conflict
+ /// with what the other hand already holds: a two-handed item needs the other hand free (ammunition
+ /// aside), and nothing but ammunition fits next to an already equipped two-handed item.
+ ///
+ /// The definition of the item which would be equipped.
+ /// The inventory to check the other hand in.
+ /// The hand slot the item would be equipped into.
+ /// true if the other hand blocks this item; otherwise, false.
+ public static bool ConflictsWithEquippedHands(this ItemDefinition itemDefinition, IStorage inventory, byte toSlot)
+ {
+ if (itemDefinition.ItemSlot is null)
+ {
+ return false;
+ }
+
+ static bool IsOneHandedOrShield(ItemDefinition definition) =>
+ (definition.ItemSlot!.ItemSlots.Contains(InventoryConstants.RightHandSlot) && definition.ItemSlot.ItemSlots.Contains(InventoryConstants.LeftHandSlot))
+ || definition.Group == ShieldItemGroup;
+
+ var rightHandItemDefinition = inventory.GetItem(InventoryConstants.RightHandSlot)?.Definition;
+
+ return (toSlot == InventoryConstants.LeftHandSlot
+ && itemDefinition.Width >= 2
+ && rightHandItemDefinition is not null
+ && !rightHandItemDefinition.IsAmmunition)
+ || (toSlot == InventoryConstants.RightHandSlot
+ && IsOneHandedOrShield(itemDefinition)
+ && inventory.GetItem(InventoryConstants.LeftHandSlot)?.Definition?.Width >= 2);
+ }
+
///
/// Determines whether this item is a jewelry (pendant or ring) item.
///
diff --git a/src/GameLogic/MuHelper/IMuHelperSettings.cs b/src/GameLogic/MuHelper/IMuHelperSettings.cs
index 9dad4bdc1..2ade57c66 100644
--- a/src/GameLogic/MuHelper/IMuHelperSettings.cs
+++ b/src/GameLogic/MuHelper/IMuHelperSettings.cs
@@ -150,6 +150,50 @@ public interface IMuHelperSettings
/// Gets a value indicating whether to automatically accept requests from guild.
bool AutoAcceptGuild { get; }
+ ///
+ /// Gets a value indicating whether to automatically accept party requests from anyone, not just
+ /// friends or guild mates. Defaults to false; used by server-side bots so they group up
+ /// with players who invite them (see Bots.BotPartyHandler for the applied safeguards).
+ ///
+ bool AutoAcceptAnyone => false;
+
/// Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used.
bool FallbackBasicAttack { get; }
+
+ ///
+ /// Gets a value indicating whether the combat AI should automatically cast the strongest learned
+ /// attack skill the character can currently afford, instead of relying on the explicitly configured
+ /// skill IDs. Used by server-side bots (which have no client-side MU Helper config) so they fight
+ /// with class- and level-appropriate skills; human offline sessions keep their explicit configuration.
+ ///
+ bool AutoSelectBestSkill { get; }
+
+ ///
+ /// Gets a value indicating whether the buff AI should automatically cast the learned buff skills
+ /// of the character, instead of relying on the explicitly configured buff slot IDs. Used by
+ /// server-side bots so each class keeps its own buffs up (e.g. elf Greater Defense/Greater Damage);
+ /// human offline sessions keep their explicit configuration.
+ ///
+ bool AutoSelectBuffs { get; }
+
+ ///
+ /// Gets a value indicating whether to drink a mana potion when mana runs low, so casters can keep
+ /// casting. There is no client-side MU Helper setting for this; it is used by server-side bots.
+ ///
+ bool UseManaPotion { get; }
+
+ ///
+ /// Gets a value indicating whether the combat AI only engages monsters the character can safely
+ /// handle (up to half its own level, like the bot navigator's hunting-ground selection). Without
+ /// this, a bot travelling through hostile territory would pick fights with monsters far above its
+ /// level and die. Human offline sessions keep the unrestricted behavior - the player chose the spot.
+ ///
+ bool OnlyHuntSafeMonsters { get; }
+
+ ///
+ /// Gets a value indicating whether to also pick up equippable items which are an upgrade over the
+ /// character's currently equipped gear (evaluated before pickup), so bots progress their equipment
+ /// like a real player without hoarding junk.
+ ///
+ bool PickUpgradeItems { get; }
}
diff --git a/src/GameLogic/MuHelper/PartyRequestHandler.cs b/src/GameLogic/MuHelper/PartyRequestHandler.cs
index ff190b024..5f3419b4d 100644
--- a/src/GameLogic/MuHelper/PartyRequestHandler.cs
+++ b/src/GameLogic/MuHelper/PartyRequestHandler.cs
@@ -38,6 +38,13 @@ public static async ValueTask TryAutoAcceptPartyRequestAsync(Player receiv
return true;
}
+ if (settings.AutoAcceptAnyone && await Bots.BotPartyHandler.TryScheduleAcceptAsync(receiver, requester).ConfigureAwait(false))
+ {
+ // The actual accept happens shortly afterwards in the bot's own tick (a human-like delay);
+ // all bot-specific safeguards live in the handler, so this stays a thin criteria branch.
+ return true;
+ }
+
return false;
}
diff --git a/src/GameLogic/Offline/BuffHandler.cs b/src/GameLogic/Offline/BuffHandler.cs
index 2f9fe9ec9..bd2ff8abf 100644
--- a/src/GameLogic/Offline/BuffHandler.cs
+++ b/src/GameLogic/Offline/BuffHandler.cs
@@ -25,6 +25,8 @@ public sealed class BuffHandler
private int _nextSlotIndex;
private bool _buffTimerTriggered;
private DateTime? _nextPeriodicBuffTime;
+ private IList? _cachedAutoBuffIds;
+ private int _cachedAutoBuffSkillCount = -1;
///
/// Initializes a new instance of the class.
@@ -38,7 +40,9 @@ public BuffHandler(OfflinePlayer player, IMuHelperSettings? config)
}
///
- /// Gets the configured buff skill IDs from the settings.
+ /// Gets the configured buff skill IDs from the settings. With
+ /// enabled (server-side bots), the character's learned buff skills are used instead of the explicitly
+ /// configured slots, so each class keeps its own buffs up without any per-character configuration.
///
public IList ConfiguredBuffIds
{
@@ -49,6 +53,34 @@ public IList ConfiguredBuffIds
return [];
}
+ if (this._config.AutoSelectBuffs && this._player.SkillList is { } skillList)
+ {
+ // The learned buffs only change when a new skill is learned, so the list is cached and
+ // only rebuilt when the skill count changes - building it fresh with LINQ on every
+ // 500ms tick of hundreds of bots was measurable CPU for no benefit.
+ var skillCount = skillList.Skills.Count();
+ if (this._cachedAutoBuffIds is null || skillCount != this._cachedAutoBuffSkillCount)
+ {
+ var learnedBuffs = skillList.Skills
+ .Where(s => s.Skill is { SkillType: SkillType.Buff, MagicEffectDef: not null })
+ .Select(s => (int)s.Skill!.Number)
+ .OrderBy(n => n)
+ .Take(BuffSlotCount)
+ .ToList();
+
+ // Pad to the fixed slot count - the caller indexes all three slots; 0 means "slot empty".
+ while (learnedBuffs.Count < BuffSlotCount)
+ {
+ learnedBuffs.Add(0);
+ }
+
+ this._cachedAutoBuffIds = learnedBuffs;
+ this._cachedAutoBuffSkillCount = skillCount;
+ }
+
+ return this._cachedAutoBuffIds;
+ }
+
return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id];
}
}
@@ -246,8 +278,21 @@ private bool IsEffectActive(IAttackable target, SkillEntry skillEntry)
return false;
}
- return target.MagicEffectList.ActiveEffects.Values
- .Any(e => e.Definition == effectDef);
+ try
+ {
+ // Eager snapshot, like the other readers of ActiveEffects (see MagicEffectsList): the
+ // list is mutated by the effect expiry timers, and a lazy enumeration from this
+ // (unsynchronized) helper tick raced them regularly at scale. A list shrinking in the
+ // middle of the copy can still leave null holes in the snapshot (hence the tolerant
+ // predicate) or throw out of the copy itself (hence the catch-all around this pure
+ // read) - any torn read simply counts as "active", and the next tick retries.
+ var activeEffects = target.MagicEffectList.ActiveEffects.Values.ToArray();
+ return activeEffects.Any(e => e?.Definition == effectDef);
+ }
+ catch (Exception)
+ {
+ return true;
+ }
}
private void UpdatePeriodicBuffTimer()
diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs
index 1ed9a3023..035cef885 100644
--- a/src/GameLogic/Offline/CombatHandler.cs
+++ b/src/GameLogic/Offline/CombatHandler.cs
@@ -4,7 +4,10 @@
namespace MUnique.OpenMU.GameLogic.Offline;
+using System.Collections.Concurrent;
+using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
@@ -19,26 +22,82 @@ public sealed class CombatHandler
{
private const byte DefaultRange = 1;
private const byte BowRange = 6;
+
+ ///
+ /// See : the largest share of the bot's maximum health a single average
+ /// monster hit may take for the monster to count as safe. Sized so the bot survives several hits
+ /// even when a few monsters aggro at once, with the healing handler (potions at 60%) keeping up.
+ /// Tightened from 0.20 with the player-meta stat builds: their small health pools mean melee bots
+ /// (which stand inside the monster pack) need a bigger margin per hit to survive a swarm.
+ ///
+ private const float SafeHitHealthShare = 0.15f;
+
+ ///
+ /// See : the bot's attack power must exceed the monster's defense by this
+ /// factor. Without it a bot picks fights it can barely scratch - e.g. the Vulcanus tank monsters
+ /// (defense ~340, health ~100k) shrug off a modestly geared bot's hits, the "fight" lasts minutes,
+ /// and the accumulated damage kills the bot even though every single hit it takes looks survivable.
+ ///
+ private const float MinAttackAdvantage = 1.2f;
+
+ ///
+ /// See : the monster must die within this many net hits of the bot.
+ /// The per-hit checks alone let a fighter "safely" besiege a 100k-health tank monster for ten
+ /// minutes, until its potions ran dry and it died anyway - the fight length itself is the risk.
+ /// At the offline AI's attack pace this bounds a kill to roughly a minute or two.
+ ///
+ private const int MaxHitsToKill = 100;
+
+ ///
+ /// See : how much longer a mastered bot may take to kill a monster which
+ /// pays master experience. Master experience is only granted for monsters of at least
+ /// GameConfiguration.MinimumMonsterLevelForMasterExperience, and those hold 40.000+ health -
+ /// out of reach of the regular hit budget for a bot in the gear it collects from drops, which left
+ /// mastered bots hunting monsters that pay them nothing at all. Since a character at the maximum
+ /// level earns nothing else either, a long fight it survives beats a quick one worth zero: the
+ /// budget is stretched for those monsters only, while the survivability check below is NOT - a bot
+ /// still refuses a monster whose hits it cannot take.
+ ///
+ private const int MasterHitBudgetFactor = 3;
private const int ComboFinisherDelayTicks = 3;
private const int InterSkillDelayTicks = 1;
private const int MinComboSkillCount = 3;
+ /// After this many consecutive failed approaches the target counts as unreachable.
+ private const int MaxApproachFailures = 3;
+
private const short DrainLifeBaseSkillId = 214;
private const short DrainLifeStrengthenerSkillId = 458;
private const short DrainLifeMasterySkillId = 462;
+ /// How long an unreachable target is ignored before it may be considered again.
+ private static readonly TimeSpan UnreachableTargetBlacklistDuration = TimeSpan.FromSeconds(10);
+
private static readonly TargetedSkillDefaultPlugin DefaultPlugin = new();
+ ///
+ /// Cache of the combat-relevant stats of monster definitions (config data, immutable at runtime),
+ /// so the safety checks of hundreds of bots don't re-scan the attribute lists every tick. Keyed by
+ /// the monster number rather than the definition instance, so a configuration reload (which builds
+ /// new instances) reuses the entries instead of orphaning them.
+ ///
+ private static readonly ConcurrentDictionary MonsterStatsCache = new();
+
private readonly OfflinePlayer _player;
private readonly IMuHelperSettings? _config;
private readonly MovementHandler _movementHandler;
- private readonly Point _originPosition;
private readonly ConditionalSkillSlot[] _conditionalSkillSlots;
private IAttackable? _currentTarget;
private int _nearbyMonsterCount;
private int _currentComboStep;
private int _skillCooldownTicks;
+ private int _approachFailures;
+ private ushort _unreachableTargetId;
+ private DateTime _unreachableTargetUntilUtc = DateTime.MinValue;
+ private SkillEntry? _tickBestSkill;
+ private bool _tickBestSkillComputed;
+ private DateTime _engageAtUtc = DateTime.MinValue;
///
/// Initializes a new instance of the class.
@@ -46,13 +105,11 @@ public sealed class CombatHandler
/// The offline player.
/// The MU helper settings.
/// The movement handler.
- /// The original position to hunt around.
- public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHandler movementHandler, Point originPosition)
+ public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHandler movementHandler)
{
this._player = player;
this._config = config;
this._movementHandler = movementHandler;
- this._originPosition = originPosition;
this._conditionalSkillSlots = config is null ? [] :
[
new ConditionalSkillSlot(config.ActivationSkill1Id, config.Skill1UseTimer, config.DelayMinSkill1, config.Skill1UseCondition, config.Skill1ConditionAttacking, config.Skill1SubCondition),
@@ -70,6 +127,19 @@ public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHa
///
public byte HuntingRange => CalculateHuntingRange(this._config);
+ ///
+ /// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds.
+ ///
+ private Point OriginPosition => this._player.HuntingOrigin;
+
+ ///
+ /// Gets a value indicating whether this session animates a server-side bot rather than the offline
+ /// session of a real player. Bots trade a bit of hunting efficiency for looking human (reaction
+ /// delay, target spread); a player's offline session must behave exactly as it did before the bots
+ /// moved into this handler.
+ ///
+ private bool IsBot => this._player.Account?.IsBot == true;
+
///
/// Calculates the hunting range in tiles from the specified configuration.
///
@@ -85,6 +155,72 @@ public static byte CalculateHuntingRange(IMuHelperSettings? config)
return (byte)Math.Max(DefaultRange, config.HuntingRange);
}
+ ///
+ /// Determines whether the monster is one the bot can fight without dying, judged by the monster's
+ /// REAL combat stats instead of its nominal level: the average hit it lands (its base damage minus
+ /// the bot's PvM defense, the same subtraction the damage formula applies) must not exceed
+ /// of the bot's maximum health. A monster's level says nothing
+ /// about its punch - the high-end maps (Swamp of Calmness, LaCleon, the event fortresses) field
+ /// "level ~120" monsters which hit for 1000-2300 base damage, several times what regular maps'
+ /// monsters of the same level deal - so a level cap sent high-level bots in modest gear straight
+ /// into a death loop there. Judging by damage also scales naturally with equipment: better armor
+ /// raises the bot's defense and unlocks tougher maps, exactly like it does for a real player.
+ /// The bot's own offense must in turn exceed the monster's defense (),
+ /// so it never besieges a tank monster it can barely scratch, and the monster's level must not
+ /// exceed the bot's own (on reset servers: its reset-aware effective level, see
+ /// ).
+ /// Shared by the combat AI and the bot navigator, so a bot never stops travelling for (or engages)
+ /// a monster it should not fight.
+ ///
+ /// The bot player.
+ /// The monster definition.
+ public static bool IsSafeTarget(Player player, MonsterDefinition monster)
+ {
+ var (monsterLevel, averageDamage, monsterDefense, monsterHealth, monsterAttackRate) = GetMonsterCombatStats(monster);
+ if (monsterLevel <= 0 || player.Attributes is not { } attributes)
+ {
+ return false;
+ }
+
+ // Reset-aware: on servers with the reset feature a freshly reset character is nominally a
+ // low level again but keeps the strength of its resets - the effective level keeps it from
+ // being locked out of the maps it just hunted on.
+ if (monsterLevel > BotResetHandler.GetEffectiveLevel(player))
+ {
+ return false;
+ }
+
+ var netHit = Math.Max(0f, averageDamage - attributes[Stats.DefensePvm]) * GetExpectedHitShare(player, monsterAttackRate);
+ if (netHit > SafeHitHealthShare * Math.Max(1f, attributes[Stats.MaximumHealth]))
+ {
+ return false;
+ }
+
+ var attackPower = GetAttackPower(player);
+ if (attackPower <= monsterDefense * MinAttackAdvantage)
+ {
+ return false;
+ }
+
+ return (attackPower - monsterDefense) * GetHitBudget(player, monsterLevel) >= monsterHealth;
+ }
+
+ ///
+ /// The number of net hits the bot may take to kill the monster (see ),
+ /// stretched by for a mastered bot fighting a monster which
+ /// actually pays it master experience (see ).
+ ///
+ private static int GetHitBudget(Player player, int monsterLevel)
+ {
+ var configuration = player.GameContext.Configuration;
+ var isMastered = player.SelectedCharacter?.CharacterClass?.IsMasterClass == true
+ && (player.Attributes?[Stats.Level] ?? 0) >= configuration.MaximumLevel;
+
+ return isMastered && monsterLevel >= configuration.MinimumMonsterLevelForMasterExperience
+ ? MaxHitsToKill * MasterHitBudgetFactor
+ : MaxHitsToKill;
+ }
+
///
/// Decrements the skill cooldown counter by one tick.
///
@@ -101,6 +237,7 @@ public void DecrementCooldown()
///
public async ValueTask PerformAttackAsync()
{
+ var previousTarget = this._currentTarget;
this.RefreshTarget();
if (this._currentTarget is null)
@@ -108,13 +245,47 @@ public async ValueTask PerformAttackAsync()
return;
}
+ // A human doesn't strike the very same instant a new target appears: give each fresh target a
+ // small randomized reaction delay (the bot faces it, then engages) - the perfectly metronomic
+ // instant-strike cadence is one of the clearest bot giveaways. Only bots pay for the disguise:
+ // a player's own offline session must hunt exactly as fast as it always did.
+ if (this.IsBot)
+ {
+ if (!ReferenceEquals(previousTarget, this._currentTarget))
+ {
+ this._engageAtUtc = DateTime.UtcNow.AddMilliseconds(Rand.NextInt(250, 900));
+ }
+
+ if (DateTime.UtcNow < this._engageAtUtc)
+ {
+ this._player.Rotation = this._player.GetDirectionTo(this._currentTarget);
+ return;
+ }
+ }
+
byte attackRange = this.GetEffectiveAttackRange();
if (!this.IsTargetInAttackRange(this._currentTarget, attackRange))
{
- await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false);
+ if (await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false))
+ {
+ this._approachFailures = 0;
+ }
+ else if (++this._approachFailures >= MaxApproachFailures)
+ {
+ // The target is in (Euclidean) range but no walkable path leads to it - e.g. a monster
+ // across a wall or river. Blacklist it briefly and drop it, so the bot picks another
+ // target (or moves on) instead of standing in front of the obstacle forever.
+ this._unreachableTargetId = this._currentTarget.Id;
+ this._unreachableTargetUntilUtc = DateTime.UtcNow + UnreachableTargetBlacklistDuration;
+ this._currentTarget = null;
+ this._approachFailures = 0;
+ }
+
return;
}
+ this._approachFailures = 0;
+
if (this._config?.UseCombo == true)
{
await this.ExecuteComboAttackAsync().ConfigureAwait(false);
@@ -157,6 +328,75 @@ public async ValueTask PerformDrainLifeRecoveryAsync()
}
}
+ private static (int Level, float AverageDamage, float Defense, float Health, float AttackRate) GetMonsterCombatStats(MonsterDefinition monster)
+ {
+ return MonsterStatsCache.GetOrAdd(
+ monster.Number,
+ static (_, m) =>
+ {
+ float GetValue(AttributeDefinition attribute)
+ => m.Attributes.FirstOrDefault(a => a.AttributeDefinition == attribute)?.Value ?? 0f;
+
+ var level = (int)GetValue(Stats.Level);
+ var averageDamage = (GetValue(Stats.MinimumPhysBaseDmg) + GetValue(Stats.MaximumPhysBaseDmg)) / 2f;
+ return (level, averageDamage, GetValue(Stats.DefenseBase), GetValue(Stats.MaximumHealth), GetValue(Stats.AttackRatePvm));
+ },
+ monster);
+ }
+
+ ///
+ /// How much of the monster's average hit actually lands on the bot, over time: the engine rolls
+ /// every monster swing against the bot's defense rate (see the hit chance in
+ /// ), so an agility-based character tanks by DODGING, not by
+ /// soaking. Judging its safety by the raw hit alone declared every such build too squishy for
+ /// anything past the starter maps - and left the whole caster population stuck on Lorencia.
+ /// The dodge credit is capped (a bot must not bet its life on a lucky evade streak).
+ ///
+ private static float GetExpectedHitShare(Player player, float monsterAttackRate)
+ {
+ const float minimumAssumedHitChance = 0.25f;
+ if (monsterAttackRate <= 0f || player.Attributes is not { } attributes)
+ {
+ return 1f;
+ }
+
+ var defenseRate = attributes[Stats.DefenseRatePvm];
+ var hitChance = defenseRate < monsterAttackRate ? 1f - (defenseRate / monsterAttackRate) : 0.03f;
+ return Math.Clamp(hitChance, minimumAssumedHitChance, 1f);
+ }
+
+ ///
+ /// A rough estimate of the bot's punch: its best base damage kind (physical for fighters, wizardry
+ /// for casters, curse for summoners) plus the strongest attack skill it has learned - enough to tell
+ /// apart "kills this monster at a reasonable pace" from "barely scratches it".
+ ///
+ private static float GetAttackPower(Player player)
+ {
+ if (player.Attributes is not { } attributes)
+ {
+ return 0f;
+ }
+
+ var physical = (attributes[Stats.MinimumPhysBaseDmg] + attributes[Stats.MaximumPhysBaseDmg]) / 2f;
+
+ // The Min/Max wizardry damage is what a caster actually hits with (energy feeds it, see the
+ // class attribute relations); Stats.WizardryBaseDmg is only the bonus channel of the staff's
+ // rise - reading it made every caster look like it had no offense at all, so it never passed
+ // the checks below for anything but the starter maps and stayed there forever.
+ var wizardry = (attributes[Stats.MinimumWizBaseDmg] + attributes[Stats.MaximumWizBaseDmg]) / 2f;
+ var curse = (attributes[Stats.MinimumCurseBaseDmg] + attributes[Stats.MaximumCurseBaseDmg]) / 2f;
+ var skillDamage = 0;
+ foreach (var entry in player.SkillList?.Skills ?? [])
+ {
+ if (entry.Skill is { AttackDamage: > 0 } skill && skill.AttackDamage > skillDamage)
+ {
+ skillDamage = skill.AttackDamage;
+ }
+ }
+
+ return Math.Max(physical, Math.Max(wizardry, curse)) + skillDamage;
+ }
+
private async ValueTask ExecuteAttackAsync(IAttackable target)
{
var skill = this.SelectAttackSkill();
@@ -170,6 +410,13 @@ private async ValueTask ExecuteAttackAsync(IAttackable target)
private async ValueTask ExecuteAttackAsync(IAttackable target, SkillEntry? skillEntry, bool isCombo)
{
+ // Last line of defense for the "bot must never become an outlaw" invariant: no strike ever
+ // leaves this handler against a player who isn't a legal PvP target right now.
+ if (target is Player playerTarget && !BotPvpRules.IsLegalPvpTarget(this._player, playerTarget))
+ {
+ return;
+ }
+
this._player.Rotation = this._player.GetDirectionTo(target);
if (skillEntry?.Skill is not { } skill)
@@ -190,6 +437,26 @@ private async ValueTask ExecuteAttackAsync(IAttackable target, SkillEntry? skill
private void RefreshTarget()
{
+ // The best-skill choice is cached for the duration of one tick (it is needed for both the range
+ // check and the actual attack); a new tick starts with a fresh choice.
+ this._tickBestSkill = null;
+ this._tickBestSkillComputed = false;
+
+ // Self-defense has priority over farming: a player who recently attacked this bot becomes the
+ // target, as long as they are still viable and anywhere near. Without this the bot placidly
+ // keeps hitting monsters while a player kills it. The aggressor memory only sets the PRIORITY,
+ // though - whether the bot may actually strike is decided by BotPvpRules per attack: the grudge
+ // outlives the game's self-defense window, and striking outside of it would turn the bot into
+ // an outlaw (see BotPvpRules.IsLegalPvpTarget).
+ if (this._config?.UseSelfDefense == true
+ && this._player.RecentAggressor is { } aggressor
+ && aggressor.IsInRange(this._player.Position, this.HuntingRange * 2)
+ && BotPvpRules.IsLegalPvpTarget(this._player, aggressor))
+ {
+ this._currentTarget = aggressor;
+ return;
+ }
+
if (this._currentTarget is { } t && !this.IsTargetStillValid(t))
{
this._currentTarget = null;
@@ -197,13 +464,24 @@ private void RefreshTarget()
if (this._currentTarget is null)
{
- var monsters = this.GetAttackableMonstersInHuntingRange().ToList();
- this._currentTarget = monsters.MinBy(m => m.GetDistanceTo(this._player));
- this._nearbyMonsterCount = monsters.Count;
+ var targets = this.GetAttackableTargetsInHuntingRange().ToList();
+ var candidates = targets
+ .Where(m => m.Id != this._unreachableTargetId || DateTime.UtcNow >= this._unreachableTargetUntilUtc)
+ .OrderBy(m => m.GetDistanceTo(this._player))
+ .Take(this.IsBot ? 2 : 1)
+ .ToList();
+
+ // A bot chooses randomly among the two nearest candidates instead of strictly the nearest
+ // one: with many bots on one ground, deterministic nearest-first makes them all dogpile the
+ // same monster and roam as a pack, which looks distinctly bot-like and wastes damage on
+ // overkill. A player's own offline session keeps hitting the nearest monster - it is his
+ // character's hunting efficiency, not a crowd to camouflage.
+ this._currentTarget = candidates.SelectRandom();
+ this._nearbyMonsterCount = targets.Count;
}
else
{
- this._nearbyMonsterCount = this.GetAttackableMonstersInHuntingRange().Count();
+ this._nearbyMonsterCount = this.GetAttackableTargetsInHuntingRange().Count();
}
}
@@ -214,11 +492,42 @@ private IEnumerable GetAttackableMonstersInHuntingRange()
return [];
}
- return map.GetAttackablesInRange(this._originPosition, this.HuntingRange)
+ return map.GetAttackablesInRange(this.OriginPosition, this.HuntingRange)
.OfType()
.Where(this.IsMonsterAttackable);
}
+ ///
+ /// The regular target pool are the attackable monsters; inside a mini game which allows player
+ /// killing (Chaos Castle) the other participants join it - there everyone is opposition, and a
+ /// bot which placidly farms monsters while being cut down would be the obvious odd one out.
+ ///
+ private IEnumerable GetAttackableTargetsInHuntingRange()
+ {
+ if (this._player.CurrentMap is not { } map)
+ {
+ return [];
+ }
+
+ var freeForAll = this._player.CurrentMiniGame is { AllowPlayerKilling: true };
+ return map.GetAttackablesInRange(this.OriginPosition, this.HuntingRange)
+ .Where(attackable => attackable switch
+ {
+ Monster monster => this.IsMonsterAttackable(monster),
+ Player player => freeForAll && this.IsEventRivalAttackable(player),
+ _ => false,
+ });
+ }
+
+ private bool IsEventRivalAttackable(Player target)
+ {
+ return !ReferenceEquals(target, this._player)
+ && target.IsAlive
+ && !target.IsAtSafezone()
+ && !target.IsTeleporting
+ && BotPvpRules.IsLegalPvpTarget(this._player, target);
+ }
+
private bool IsTargetInAttackRange(IAttackable target, byte range)
{
return target.IsInRange(this._player.Position, range);
@@ -226,17 +535,51 @@ private bool IsTargetInAttackRange(IAttackable target, byte range)
private bool IsTargetStillValid(IAttackable target)
{
+ // A player target must stay legal for the whole fight: the self-defense window can expire
+ // mid-fight (the player stopped hitting back and ran), and every further strike past that
+ // point would be an unprovoked attack that escalates the bot's own hero state.
+ if (target is Player playerTarget && !BotPvpRules.IsLegalPvpTarget(this._player, playerTarget))
+ {
+ return false;
+ }
+
return target.IsAlive
&& !target.IsAtSafezone()
&& !target.IsTeleporting
- && target.IsInRange(this._originPosition, this.HuntingRange);
+ && target.IsInRange(this.OriginPosition, this.HuntingRange);
}
private bool IsMonsterAttackable(Monster monster)
{
return monster.IsAlive
&& !monster.IsAtSafezone()
- && monster.Definition.ObjectKind == NpcObjectKind.Monster;
+ && monster.Definition.ObjectKind == NpcObjectKind.Monster
+ && this.IsWithinSafeHuntLevel(monster);
+ }
+
+ ///
+ /// With (server-side bots), the combat AI only
+ /// engages monsters which pass the same check the bot navigator hunts by.
+ /// Without this, a bot travelling through hostile territory picks a fight with any monster that
+ /// comes within range - including ones far too strong - and dies. Human offline sessions keep the
+ /// unrestricted behavior, since the player chose their hunting spot deliberately.
+ ///
+ private bool IsWithinSafeHuntLevel(Monster monster)
+ {
+ if (this._config?.OnlyHuntSafeMonsters != true)
+ {
+ return true;
+ }
+
+ if (this._player.CurrentMiniGame is not null)
+ {
+ // Inside a mini game event the opposition is not the bot's choice - it fights what
+ // the event throws at it, like every other participant. Refusing "unsafe" waves
+ // would leave the bot idling in the middle of a Blood Castle.
+ return true;
+ }
+
+ return IsSafeTarget(this._player, monster.Definition);
}
private async ValueTask ExecutePhysicalAttackAsync(IAttackable target)
@@ -274,6 +617,36 @@ await this._player.ForEachWorldObserverAsync(
{
await monster.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false);
}
+
+ // A player target is hit by the area skill as well. Outside of free-for-all events ONLY the
+ // target itself (the self-defense aggressor): any bystanding player in the blast radius is
+ // deliberately spared - a bot's self-defense must never splash uninvolved players, no
+ // matter what it casts. Inside a mini game with free player killing (Chaos Castle) there
+ // are no uninvolved players, so the skill splashes the other participants like any area
+ // skill would. The legality re-check right at the strike closes the last race: the target
+ // was legal when it was picked, but the situation may have changed in the meantime.
+ IEnumerable playerTargets;
+ if (this._player.CurrentMiniGame is { AllowPlayerKilling: true })
+ {
+ playerTargets = this._player.CurrentMap?
+ .GetAttackablesInRange(target.Position, skill.Range)
+ .OfType()
+ .Where(p => !ReferenceEquals(p, this._player))
+ ?? [];
+ }
+ else
+ {
+ playerTargets = target is Player playerTarget ? [playerTarget] : [];
+ }
+
+ foreach (var player in playerTargets)
+ {
+ if (player.IsAlive && !player.IsAtSafezone()
+ && BotPvpRules.IsLegalPvpTarget(this._player, player))
+ {
+ await player.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false);
+ }
+ }
}
private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skill skill)
@@ -290,10 +663,12 @@ private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skil
return null;
}
- // If no skills are configured at all, don't attack.
+ // If no skills are configured at all, don't attack - unless the AI is allowed to pick a skill
+ // on its own (bots), in which case we fall through to the automatic selection below.
if (this._config.BasicSkillId == 0
&& this._config.ActivationSkill1Id == 0
- && this._config.ActivationSkill2Id == 0)
+ && this._config.ActivationSkill2Id == 0
+ && !this._config.AutoSelectBestSkill)
{
return null;
}
@@ -316,9 +691,64 @@ private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skil
}
}
+ // No explicitly configured skill fired: let the AI pick the strongest affordable learned attack
+ // skill. This scales with the character's level and mana pool, so higher-level bots naturally cast
+ // stronger spells, and drop back to a basic attack (via FallbackBasicAttack) only when out of mana.
+ if (this._config.AutoSelectBestSkill)
+ {
+ return this.SelectBestAffordableSkill();
+ }
+
return null;
}
+ ///
+ /// Picks the strongest attack skill the character has learned and can currently afford (enough mana
+ /// and ability). Only attack skills (direct hit or area damage) are considered; learned skills are
+ /// always class-qualified, so this can never cast a skill the class is not entitled to.
+ ///
+ private SkillEntry? SelectBestAffordableSkill()
+ {
+ if (this._tickBestSkillComputed)
+ {
+ // Computed once per tick: both the attack-range check and the attack itself need it.
+ return this._tickBestSkill;
+ }
+
+ this._tickBestSkillComputed = true;
+ if (this._player.SkillList is not { } skillList)
+ {
+ return null;
+ }
+
+ SkillEntry? best = null;
+ var bestDamage = 0;
+ foreach (var entry in skillList.Skills)
+ {
+ if (entry.Skill is not { } skill || skill.AttackDamage <= 0)
+ {
+ continue;
+ }
+
+ if (skill.SkillType is not (SkillType.DirectHit
+ or SkillType.AreaSkillAutomaticHits
+ or SkillType.AreaSkillExplicitHits
+ or SkillType.AreaSkillExplicitTarget))
+ {
+ continue;
+ }
+
+ if (skill.AttackDamage > bestDamage && this.HasEnoughResources(entry))
+ {
+ best = entry;
+ bestDamage = skill.AttackDamage;
+ }
+ }
+
+ this._tickBestSkill = best;
+ return best;
+ }
+
///
/// Evaluates whether the skill in the given slot should fire this tick.
///
@@ -514,6 +944,15 @@ private byte GetEffectiveAttackRange()
}
}
+ // Bots have no configured skill IDs but auto-select their attack skill; use the range of the skill
+ // they would actually cast now, so ranged casters attack from a distance instead of closing to melee.
+ if (this._config.AutoSelectBestSkill
+ && this.SelectBestAffordableSkill()?.Skill?.Range is { } autoRange
+ && autoRange > 0)
+ {
+ return (byte)autoRange;
+ }
+
if (this._player.Attributes is { } attributes
&& (attributes[Stats.IsBowEquipped] > 0 || attributes[Stats.IsCrossBowEquipped] > 0))
{
diff --git a/src/GameLogic/Offline/HealingHandler.cs b/src/GameLogic/Offline/HealingHandler.cs
index 480361433..0ba8af788 100644
--- a/src/GameLogic/Offline/HealingHandler.cs
+++ b/src/GameLogic/Offline/HealingHandler.cs
@@ -18,6 +18,9 @@ namespace MUnique.OpenMU.GameLogic.Offline;
///
public sealed class HealingHandler
{
+ /// Drink a mana potion once mana falls below this share, so casters can keep casting.
+ private const int ManaThresholdPercent = 30;
+
private static readonly ItemConsumeAction ConsumeAction = new();
private static readonly ItemIdentifier[] HealthPotionPriority =
@@ -28,6 +31,13 @@ public sealed class HealingHandler
ItemConstants.Apple,
];
+ private static readonly ItemIdentifier[] ManaPotionPriority =
+ [
+ ItemConstants.LargeManaPotion,
+ ItemConstants.MediumManaPotion,
+ ItemConstants.SmallManaPotion,
+ ];
+
private readonly OfflinePlayer _player;
private readonly IMuHelperSettings? _config;
@@ -75,9 +85,27 @@ await this._player.ForEachWorldObserverAsync(
if (this._config!.UseHealPotion && this.IsHealthBelowThreshold(this._player, this._config.PotionThresholdPercent))
{
await this.UseHealthPotionAsync().ConfigureAwait(false);
+ return;
+ }
+
+ if (this._config.UseManaPotion && this.IsManaBelowThreshold())
+ {
+ await this.UsePotionAsync(ManaPotionPriority).ConfigureAwait(false);
}
}
+ private bool IsManaBelowThreshold()
+ {
+ if (this._player.Attributes is not { } attributes)
+ {
+ return false;
+ }
+
+ double mana = attributes[Stats.CurrentMana];
+ double maxMana = attributes[Stats.MaximumMana];
+ return maxMana > 0 && (mana * 100.0 / maxMana) <= ManaThresholdPercent;
+ }
+
private async ValueTask PerformPartyHealingAsync()
{
if (!this._config!.AutoHealParty || this._player.Party is not { } party)
@@ -126,14 +154,16 @@ private bool IsHealthBelowThreshold(IAttackable target, int thresholdPercent)
return maxHp > 0 && (hp * 100.0 / maxHp) <= thresholdPercent;
}
- private async ValueTask UseHealthPotionAsync()
+ private ValueTask UseHealthPotionAsync() => this.UsePotionAsync(HealthPotionPriority);
+
+ private async ValueTask UsePotionAsync(ItemIdentifier[] priority)
{
if (this._player.Inventory is null)
{
return;
}
- foreach (var identifier in HealthPotionPriority)
+ foreach (var identifier in priority)
{
var potion = this._player.Inventory.Items
.FirstOrDefault(i => i.Definition?.Group == identifier.Group
diff --git a/src/GameLogic/Offline/ItemPickupHandler.cs b/src/GameLogic/Offline/ItemPickupHandler.cs
index ea473ebcf..ecc6c6479 100644
--- a/src/GameLogic/Offline/ItemPickupHandler.cs
+++ b/src/GameLogic/Offline/ItemPickupHandler.cs
@@ -120,13 +120,22 @@ private bool ShouldPickUp(Item item)
return true;
}
- if (this._config.PickAncient && item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0))
+ var isAncient = item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0);
+ var isExcellent = item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent);
+ if ((this._config.PickAncient && isAncient) || (this._config.PickExcellent && isExcellent))
{
- return true;
+ // A human's helper hoards every excellent/ancient piece - its owner sorts the treasure
+ // out later. A bot has no later: it cannot trade, so it only takes what it can actually
+ // wear as an upgrade; everything else would silt up its backpack until the loot pickup
+ // stops.
+ return this._player.Account?.IsBot != true
+ || Bots.BotEquipmentHandler.IsUpgradeFor(this._player, item);
}
- if (this._config.PickExcellent && item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent))
+ if (this._config.PickUpgradeItems && Bots.BotEquipmentHandler.IsUpgradeFor(this._player, item))
{
+ // The item is class-qualified gear which beats what the bot currently wears - worth picking
+ // up; the BotEquipmentHandler will equip it on one of its next passes.
return true;
}
diff --git a/src/GameLogic/Offline/MovementHandler.cs b/src/GameLogic/Offline/MovementHandler.cs
index 98ab06df5..a52b2460d 100644
--- a/src/GameLogic/Offline/MovementHandler.cs
+++ b/src/GameLogic/Offline/MovementHandler.cs
@@ -16,7 +16,6 @@ public sealed class MovementHandler
private readonly OfflinePlayer _player;
private readonly IMuHelperSettings? _config;
- private readonly Point _originPosition;
private DateTime? _outOfRangeSince;
@@ -25,14 +24,17 @@ public sealed class MovementHandler
///
/// The offline player.
/// The MU Helper configuration.
- /// The original spawn position.
- public MovementHandler(OfflinePlayer player, IMuHelperSettings? config, Point originPosition)
+ public MovementHandler(OfflinePlayer player, IMuHelperSettings? config)
{
this._player = player;
this._config = config;
- this._originPosition = originPosition;
}
+ ///
+ /// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds.
+ ///
+ private Point OriginPosition => this._player.HuntingOrigin;
+
///
/// Gets the hunting range in tiles.
///
@@ -51,7 +53,7 @@ public async ValueTask RegroupAsync()
if (this.ShouldRegroup(out var distance))
{
- await this.WalkToAsync(this._originPosition).ConfigureAwait(false);
+ await this.WalkToAsync(this.OriginPosition).ConfigureAwait(false);
this._outOfRangeSince = null;
return false;
}
@@ -69,13 +71,43 @@ public async ValueTask RegroupAsync()
///
/// The target to move closer to.
/// The range to stop within.
- public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range)
+ /// True, if a walk towards the target was started; false, if no path exists or walking is not possible.
+ public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range)
{
- if (this._player.CurrentMap is { } map && target.IsInRange(this._originPosition, this.HuntingRange))
+ if (this._player.CurrentMap is { } map && target.IsInRange(this.OriginPosition, this.HuntingRange))
{
- var walkTarget = map.Terrain.GetRandomCoordinate(target.Position, range);
- await this.WalkToAsync(walkTarget).ConfigureAwait(false);
+ var walkTarget = GetApproachPoint(map, this._player.Position, target.Position, range);
+ return await this.WalkToAsync(walkTarget).ConfigureAwait(false);
}
+
+ return false;
+ }
+
+ ///
+ /// Picks the point to walk to when closing in on a target: straight along the line towards it,
+ /// stopping at attack range. The previous behavior re-randomized a point around the target every
+ /// tick, which made the character zig-zag visibly towards its prey and re-path constantly.
+ /// Falls back to a random point near the target when the straight-line point is not walkable.
+ ///
+ private static Point GetApproachPoint(GameMap map, Point from, Point to, byte stopRange)
+ {
+ var dx = to.X - from.X;
+ var dy = to.Y - from.Y;
+ var distance = Math.Max(Math.Abs(dx), Math.Abs(dy));
+ if (distance <= stopRange)
+ {
+ return from;
+ }
+
+ var factor = (double)(distance - stopRange) / distance;
+ var x = (byte)Math.Clamp(from.X + (int)Math.Round(dx * factor), 0, 255);
+ var y = (byte)Math.Clamp(from.Y + (int)Math.Round(dy * factor), 0, 255);
+ if (map.Terrain.WalkMap[x, y] && !map.Terrain.SafezoneMap[x, y])
+ {
+ return new Point(x, y);
+ }
+
+ return map.Terrain.GetRandomCoordinate(to, stopRange);
}
///
@@ -120,7 +152,7 @@ private async ValueTask WalkToAsync(Point target)
private bool ShouldRegroup(out double distance)
{
- distance = this._player.GetDistanceTo(this._originPosition);
+ distance = this._player.GetDistanceTo(this.OriginPosition);
if (distance <= RegroupDistanceThreshold)
{
return false;
diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs
index 908e49f38..72ae371ef 100644
--- a/src/GameLogic/Offline/OfflinePlayer.cs
+++ b/src/GameLogic/Offline/OfflinePlayer.cs
@@ -7,16 +7,72 @@ namespace MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.Views;
+using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
///
/// An offline player that continues leveling after the real client disconnects.
///
-public sealed class OfflinePlayer : Player
+public class OfflinePlayer : Player
{
+ ///
+ /// A player who killed this bot this many times gets no (further) revenge: walking back a third
+ /// time into the same lost fight would just be a death loop feeding the killer free kills.
+ ///
+ private const int RepeatedKillThreshold = 2;
+
+ ///
+ /// How long an attack by a player stays "hot" as a self-defense target, counted from the LAST hit
+ /// (every attack refreshes it). Long enough to hold a grudge: an attacker who breaks off and comes
+ /// back within this window stays the bot's priority target instead of being forgiven after
+ /// seconds - whether it may actually be struck is decided per attack by .
+ ///
+ private static readonly TimeSpan AggressionMemory = TimeSpan.FromMinutes(5);
+
+ ///
+ /// How long a revenge stays armed after the respawn. One attempt only: if the bot has not reached
+ /// its death site within this time (long routes, fights on the way), it gives up and hunts normally.
+ ///
+ private static readonly TimeSpan RevengeDuration = TimeSpan.FromMinutes(3);
+
+ ///
+ /// How long the bot keeps away from hunting grounds near its death site after the same player
+ /// killed it repeatedly (see ).
+ ///
+ private static readonly TimeSpan DeathSiteAvoidanceDuration = TimeSpan.FromMinutes(10);
+
+ ///
+ /// How long a death counts toward . A kill by a player the bot
+ /// has not seen for this long counts as a fresh grudge again, not as a repeated one.
+ ///
+ private static readonly TimeSpan DeathCountMemory = TimeSpan.FromMinutes(30);
+
+ ///
+ /// How often each (human) player killed this bot recently, keyed by character name. Written by the
+ /// death plugin and read by the AI ticks, hence concurrent.
+ ///
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _deathsByKiller = new();
+
private OfflinePlayerMuHelper? _intelligence;
private Task? _intelligenceDisposeTask;
+ ///
+ /// The player who most recently attacked this bot, with the time of that attack. Written from the
+ /// attack path and read from the AI tick; immutable and written atomically (a single reference
+ /// store), so the two can access it without a lock and without a torn read.
+ ///
+ private volatile Aggression? _aggression;
+
+ ///
+ /// The pending (not yet armed, is null) or armed revenge.
+ /// The state object is immutable and the field is written atomically, so the death plugin and the
+ /// AI ticks can access it without a lock.
+ ///
+ private volatile RevengeState? _revenge;
+
+ /// See ; immutable and written atomically, like .
+ private volatile DeathSite? _deathSiteToAvoid;
+
///
/// Initializes a new instance of the class.
///
@@ -36,6 +92,71 @@ public OfflinePlayer(IGameContext gameContext)
///
public DateTime StartTimestamp { get; internal set; }
+ ///
+ /// Gets or sets the position the intelligence hunts around. For a plain offline player this is
+ /// the spawn position and never changes. Bots update it to roam between hunting grounds.
+ ///
+ public Point HuntingOrigin { get; set; }
+
+ ///
+ /// Gets a value indicating whether the player should keep playing after dying and respawning.
+ /// A normal offline session ends on death; bots override this to keep running forever.
+ ///
+ public virtual bool RespawnAndContinue => false;
+
+ ///
+ /// Gets actions queued from outside the AI tick (e.g. skill learning on level-up), which the
+ /// drains at the start of each tick. This serializes such
+ /// mutations with the combat handler, so e.g. the skill list is never modified while combat is
+ /// enumerating it.
+ ///
+ internal System.Collections.Concurrent.ConcurrentQueue> PendingBotActions { get; } = new();
+
+ ///
+ /// Gets the player who most recently attacked this bot (self-defense target), if the aggression
+ /// is recent enough and the aggressor is still a viable target.
+ ///
+ internal Player? RecentAggressor
+ {
+ get
+ {
+ if (this._aggression is { } aggression
+ && DateTime.UtcNow - aggression.AtUtc <= AggressionMemory
+ && aggression.Aggressor.IsAlive
+ && !aggression.Aggressor.IsAtSafezone())
+ {
+ return aggression.Aggressor;
+ }
+
+ return null;
+ }
+ }
+
+ ///
+ /// Gets or sets the pending party invitation from a player, scheduled by
+ /// and executed with a human-like delay in the bot's tick.
+ ///
+ internal Bots.PendingPartyInvite? PendingPartyInvite { get; set; }
+
+ ///
+ /// Gets or sets the time at which the bot gets bored of its current party with a human player
+ /// and politely leaves it (managed by ).
+ ///
+ internal DateTime? PartyBoredomAtUtc { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the bot is currently on a shopping trip (walking to
+ /// or trading with a merchant), maintained by . While on an errand
+ /// the bot declines party invitations, like a busy player would.
+ ///
+ internal bool IsOnShoppingTrip { 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.
+ ///
+ internal bool HasRevengeIntent => this._revenge is not null;
+
///
/// Initializes the offline player by loading the account fresh from the database.
///
@@ -70,6 +191,8 @@ public async ValueTask InitializeAsync(string loginName, string characterN
await this.ClientReadyAfterMapChangeAsync().ConfigureAwait(false);
+ this.HuntingOrigin = this.Position;
+
this.StartIntelligence();
this.Logger.LogDebug(
@@ -90,11 +213,188 @@ public async ValueTask InitializeAsync(string loginName, string characterN
///
/// Stops the offline player and removes it from the world.
///
- public async ValueTask StopAsync()
+ public virtual async ValueTask StopAsync()
{
await this.DisconnectAsync().ConfigureAwait(false);
}
+ ///
+ /// Registers a player who attacked this bot, so the combat AI can defend itself.
+ ///
+ /// The player who attacked this bot.
+ internal void RegisterAggressor(Player aggressor)
+ {
+ this._aggression = new Aggression(aggressor, DateTime.UtcNow);
+ }
+
+ ///
+ /// Registers that a (human) player killed this bot. The first kill makes a revenge pending: after
+ /// respawning on the same map, the bot marches back to the place of its death (driven by the
+ /// ) with re-armed aggressor memory, so it attacks the killer on
+ /// sight. A repeated kill by the same player (see ) cancels
+ /// revenge instead and makes the bot avoid hunting grounds near the death site for a while.
+ ///
+ /// The player who killed this bot.
+ internal void RegisterDeathByPlayer(Player killer)
+ {
+ if (this.CurrentMap?.Definition is not { } deathMap)
+ {
+ return;
+ }
+
+ var now = DateTime.UtcNow;
+ var deathPosition = this.Position;
+ var record = this._deathsByKiller.AddOrUpdate(
+ killer.Name,
+ _ => new DeathRecord(1, now),
+ (_, existing) => now - existing.LastDeathUtc > DeathCountMemory
+ ? new DeathRecord(1, now)
+ : new DeathRecord(existing.Count + 1, now));
+
+ if (record.Count >= RepeatedKillThreshold)
+ {
+ this._revenge = null;
+ this._deathSiteToAvoid = new DeathSite(deathPosition, deathMap, now + DeathSiteAvoidanceDuration);
+ this.Logger.LogInformation(
+ "Bot '{Name}' was killed by '{Killer}' again; giving up on revenge and avoiding the area around {Position} for a while.",
+ this.Name,
+ killer.Name,
+ deathPosition);
+ return;
+ }
+
+ this._revenge = new RevengeState(killer, deathPosition, deathMap, null);
+ }
+
+ ///
+ /// Arms a pending revenge once the bot respawned, called by the
+ /// when a bot resumes after death. Only a respawn on the map the bot died on qualifies (from any
+ /// other map the march back would be meaningless); the aggressor memory is re-armed, so the combat
+ /// AI keeps the killer prioritized (struck only when legal, see ),
+ /// and the revenge gets its time-to-live.
+ ///
+ internal void ArmRevengeAfterRespawn()
+ {
+ if (this._revenge is not { ExpiresAtUtc: null } revenge)
+ {
+ return;
+ }
+
+ if (!object.Equals(this.CurrentMap?.Definition, revenge.DeathMap))
+ {
+ this._revenge = null;
+ return;
+ }
+
+ this._revenge = revenge with { ExpiresAtUtc = DateTime.UtcNow + RevengeDuration };
+ this.RegisterAggressor(revenge.Killer);
+ this.Logger.LogInformation("Bot '{Name}' returns to avenge its death against '{Killer}'.", this.Name, revenge.Killer.Name);
+ }
+
+ ///
+ /// Gets the destination of an armed, still running revenge. Expires the revenge when its
+ /// time-to-live ran out or the bot is no longer on the map it died on (e.g. it warped away).
+ ///
+ /// The map the bot is currently on.
+ /// The place of the bot's death to march back to.
+ /// true if a revenge is active and was set.
+ internal bool TryGetRevengeDestination(GameMapDefinition currentMap, out Point deathSite)
+ {
+ deathSite = default;
+ if (this._revenge is not { ExpiresAtUtc: { } expiresAt } revenge)
+ {
+ return false;
+ }
+
+ if (DateTime.UtcNow > expiresAt)
+ {
+ this.ExpireRevenge("it timed out before the bot reached the death site");
+ return false;
+ }
+
+ if (!object.Equals(currentMap, revenge.DeathMap))
+ {
+ this.ExpireRevenge("the bot left the map it died on");
+ return false;
+ }
+
+ deathSite = revenge.DeathPosition;
+ return true;
+ }
+
+ ///
+ /// Ends an active revenge - the single attempt is spent, the bot returns to its normal routine.
+ /// The aggressor memory is deliberately left armed: if the killer is still around, the combat AI
+ /// engages it, and if it strikes again, self-defense re-arms the memory anyway.
+ ///
+ /// Why the revenge ended, for the log.
+ internal void ExpireRevenge(string reason)
+ {
+ if (this._revenge is { } revenge)
+ {
+ this._revenge = null;
+ this.Logger.LogInformation("Bot '{Name}' revenge against '{Killer}' ended: {Reason}.", this.Name, revenge.Killer.Name, reason);
+ }
+ }
+
+ ///
+ /// Gets the death site the bot should keep away from when picking a hunting ground - set after the
+ /// same player killed it repeatedly, so it stops walking back into the same lost fight.
+ ///
+ /// The map the bot is currently on.
+ /// The place of the repeated deaths.
+ /// true if an avoidance is active on the given map and was set.
+ internal bool TryGetDeathSiteToAvoid(GameMapDefinition currentMap, out Point deathSite)
+ {
+ deathSite = default;
+ if (this._deathSiteToAvoid is not { } site
+ || DateTime.UtcNow > site.AvoidUntilUtc
+ || !object.Equals(currentMap, site.Map))
+ {
+ return false;
+ }
+
+ deathSite = site.Position;
+ return true;
+ }
+
+ ///
+ /// Executes and removes all queued .
+ ///
+ internal async ValueTask DrainPendingBotActionsAsync()
+ {
+ while (this.PendingBotActions.TryDequeue(out var action))
+ {
+ try
+ {
+ await action().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ this.Logger.LogError(ex, "Queued bot action failed for {Account}.", this.AccountLoginName);
+ }
+ }
+ }
+
+ ///
+ /// Called when an AI tick of this player finished without an exception. Does nothing here - a bot
+ /// uses it to forget earlier failures (see ).
+ ///
+ internal virtual void OnAiTickSucceeded()
+ {
+ // Nothing to do for a plain offline player.
+ }
+
+ ///
+ /// Called when an AI tick of this player threw. Does nothing here, so the human offline mode keeps
+ /// behaving exactly as before; a bot counts the failures and asks for a restart when they don't stop
+ /// (see ).
+ ///
+ internal virtual void OnAiTickFailed()
+ {
+ // Nothing to do for a plain offline player.
+ }
+
///
protected override async ValueTask InternalDisconnectAsync()
{
@@ -132,6 +432,15 @@ protected override async ValueTask DisposeAsyncCore()
protected override ICustomPlugInContainer CreateViewPlugInContainer()
=> new OfflineViewPlugInContainer(this);
+ ///
+ /// Starts the intelligence which drives this offline player. Overridden by bots to also run navigation.
+ ///
+ protected virtual void StartIntelligence()
+ {
+ this._intelligence = new OfflinePlayerMuHelper(this);
+ this._intelligence.Start();
+ }
+
private async ValueTask AdvanceToCharacterSelectionStateAsync()
{
// Advance state to allow the intelligence to perform actions.
@@ -146,9 +455,24 @@ private async ValueTask SetupCharacterAsync(Character character)
await this.SetSelectedCharacterAsync(character).ConfigureAwait(false);
}
- private void StartIntelligence()
- {
- this._intelligence = new OfflinePlayerMuHelper(this);
- this._intelligence.Start();
- }
-}
\ No newline at end of file
+ ///
+ /// How often (and how recently) a specific player killed this bot.
+ ///
+ private sealed record DeathRecord(int Count, DateTime LastDeathUtc);
+
+ ///
+ /// A revenge for a death by a player's hand: pending while is null
+ /// (the bot has not respawned yet), armed and running once it is set.
+ ///
+ private sealed record RevengeState(Player Killer, Point DeathPosition, GameMapDefinition DeathMap, DateTime? ExpiresAtUtc);
+
+ ///
+ /// A death site the bot avoids when picking hunting grounds, after repeated deaths there.
+ ///
+ private sealed record DeathSite(Point Position, GameMapDefinition Map, DateTime AvoidUntilUtc);
+
+ ///
+ /// The most recent aggression against this bot: who attacked and when.
+ ///
+ private sealed record Aggression(Player Aggressor, DateTime AtUtc);
+}
diff --git a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs
index cc4027b67..73bc9a13e 100644
--- a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs
+++ b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs
@@ -46,14 +46,13 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
public OfflinePlayerMuHelper(OfflinePlayer player)
{
this._player = player;
- var originalPosition = player.Position;
var config = player.MuHelperSettings;
this._buffHandler = new BuffHandler(player, config);
this._healingHandler = new HealingHandler(player, config);
this._itemPickupHandler = new ItemPickupHandler(player, config);
- this._movementHandler = new MovementHandler(player, config, originalPosition);
- this._combatHandler = new CombatHandler(player, config, this._movementHandler, originalPosition);
+ this._movementHandler = new MovementHandler(player, config);
+ this._combatHandler = new CombatHandler(player, config, this._movementHandler);
this._repairHandler = new RepairHandler(player, config);
this._zenHandler = new ZenConsumptionHandler(player);
this._petHandler = new PetHandler(player, config);
@@ -120,10 +119,17 @@ private void OnPlayerDied(DeathInformation e)
{
this._player.Logger.LogDebug("Offline player '{Name}' died. Killer: {KillerName}.", this._player.Name, e.KillerName);
this._isDead = true;
+
+ // Do not cancel the loop here: a bot needs to keep ticking so it can resume after respawning.
+ // For a normal offline session the tick stops the session on respawn, which disposes (and cancels) this helper.
}
private async Task RunLoopAsync(CancellationToken cancellationToken)
{
+ // Randomize the loop phase, so hundreds of concurrently started players don't all tick on the
+ // same 500ms boundary - smoother server load and less robotic synchrony between them.
+ await Task.Delay(Rand.NextInt(0, 500), cancellationToken).ConfigureAwait(false);
+
while (await this._timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
cancellationToken.ThrowIfCancellationRequested();
@@ -136,6 +142,7 @@ private async Task SafeTickAsync(CancellationToken cancellationToken)
try
{
await this.TickAsync(cancellationToken).ConfigureAwait(false);
+ this._player.OnAiTickSucceeded();
}
catch (OperationCanceledException)
{
@@ -144,11 +151,16 @@ private async Task SafeTickAsync(CancellationToken cancellationToken)
catch (Exception ex)
{
this._player.Logger.LogError(ex, "Error in offline player helper tick for {AccountLoginName}.", this._player.AccountLoginName);
+ this._player.OnAiTickFailed();
}
}
private async ValueTask TickAsync(CancellationToken cancellationToken)
{
+ // Actions queued from outside the tick (e.g. skill learning on level-up) run here, serialized
+ // with the combat handler - so nothing mutates the skill list while combat is enumerating it.
+ await this._player.DrainPendingBotActionsAsync().ConfigureAwait(false);
+
if (await this.HandleDeathAsync().ConfigureAwait(false))
{
return;
@@ -215,6 +227,19 @@ private async ValueTask HandleDeathAsync()
return true;
}
+ if (this._player.RespawnAndContinue)
+ {
+ // Bots keep playing: reset the death state and re-anchor the hunting origin to the respawn
+ // position so the navigator picks a fresh hunting ground from where the bot came back to life.
+ // A death by a player's hand may have left a pending revenge - arm it now (the navigator
+ // then marches the bot back to its death site instead of picking a hunting ground).
+ this._isDead = false;
+ this._player.HuntingOrigin = this._player.Position;
+ this._player.ArmRevengeAfterRespawn();
+ this._player.Logger.LogInformation("Bot '{Name}' respawned; resuming.", this._player.Name);
+ return false;
+ }
+
if (this._player.Account?.LoginName is { } loginName)
{
this._player.Logger.LogInformation("Offline player died and successfully respawned. Stopping session for {0}.", loginName);
diff --git a/src/GameLogic/Offline/ZenConsumptionHandler.cs b/src/GameLogic/Offline/ZenConsumptionHandler.cs
index 0cd5d7709..85f170168 100644
--- a/src/GameLogic/Offline/ZenConsumptionHandler.cs
+++ b/src/GameLogic/Offline/ZenConsumptionHandler.cs
@@ -34,6 +34,14 @@ public ZenConsumptionHandler(OfflinePlayer player)
/// true if the player can continue; false if insufficient Zen.
public async ValueTask DeductZenAsync()
{
+ // Bots are exempt from the PC-Cafe fee: they don't accumulate Zen fast enough
+ // to cover it and would otherwise go bankrupt and stop. Human offline-leveling
+ // players (IsBot == false) keep paying as before.
+ if (this._player.Account?.IsBot == true)
+ {
+ return true;
+ }
+
if (DateTime.UtcNow - this._lastPayTimestamp < this._configuration.PayInterval)
{
return true;
diff --git a/src/GameLogic/PlayerActions/Items/MoveItemAction.cs b/src/GameLogic/PlayerActions/Items/MoveItemAction.cs
index 488c775fa..d0bdef6a8 100644
--- a/src/GameLogic/PlayerActions/Items/MoveItemAction.cs
+++ b/src/GameLogic/PlayerActions/Items/MoveItemAction.cs
@@ -263,18 +263,7 @@ private async ValueTask CanMoveAsync(Player player, Item item, byte to
if (itemDefinition.ItemSlot.ItemSlots.Contains(toSlot) &&
player.CompliesRequirements(item))
{
- static bool IsOneHandedOrShield(ItemDefinition definition) =>
- (definition.ItemSlot!.ItemSlots.Contains(RightHandSlot) && definition.ItemSlot.ItemSlots.Contains(LeftHandSlot)) || definition.Group == 6;
-
- var rightHandItemDefinition = storage.GetItem(RightHandSlot)?.Definition!;
-
- if ((toSlot == LeftHandSlot
- && itemDefinition.Width >= 2
- && rightHandItemDefinition != null
- && !rightHandItemDefinition.IsAmmunition)
- || (toSlot == RightHandSlot
- && IsOneHandedOrShield(itemDefinition)
- && storage.GetItem(LeftHandSlot)?.Definition!.Width >= 2))
+ if (itemDefinition.ConflictsWithEquippedHands(storage, toSlot))
{
// Attempting to equip a two-handed item to the left hand slot when a shield is in the right hand slot,
// or trying to equip a one-handed weapon or shield to the right hand slot when a two-handed item is in the left hand slot.
diff --git a/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs b/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs
index 01c2724be..2d180fc50 100644
--- a/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs
+++ b/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs
@@ -99,6 +99,10 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam
var entrance = miniGameDefinition.Entrance ?? throw new InvalidOperationException("mini game entrance not defined");
var miniGame = await player.GameContext.GetMiniGameAsync(miniGameDefinition, player).ConfigureAwait(false);
+ // Snapshot before entering: an event which disallows parties (Chaos Castle) kicks the
+ // entering player out of its party below, losing the knowledge of who was going to follow.
+ var partyBots = Bots.BotMiniGameHandler.SnapshotPartyBots(player);
+
var enterResult = await miniGame.TryEnterAsync(player).ConfigureAwait(false);
if (enterResult == EnterResult.Success)
{
@@ -125,6 +129,10 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam
await player.RemoveSummonAsync().ConfigureAwait(false);
await player.MagicEffectList.ClearEffectsAfterDeathAsync().ConfigureAwait(false);
await player.WarpToAsync(entrance).ConfigureAwait(false);
+
+ // The bots of the entering party leader follow them in (each checked against the same
+ // entry restrictions, no ticket needed - the leader's own ticket legitimizes the visit).
+ Bots.BotMiniGameHandler.BringPartyBotsAlong(player, partyBots, miniGameDefinition, miniGame);
}
else
{
diff --git a/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs b/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs
index c10bf92ef..595cc4797 100644
--- a/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs
+++ b/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs
@@ -28,7 +28,12 @@ public async ValueTask HandlePartyRequestAsync(Player player, Player toRequest)
if (toRequest.Party != null || toRequest.LastPartyRequester != null)
{
- if (toRequest.Party != null && Equals(toRequest.Party.PartyMaster, toRequest))
+ // A server-side bot is asked as well when it is a plain member of its (bot) party: a living
+ // player takes precedence over the bot's own company, so it leaves that party and joins the
+ // inviter (see BotPartyHandler). Everyone else keeps the original rule - only the master of a
+ // party can answer an invitation.
+ var isBot = toRequest.Account?.IsBot == true;
+ if (toRequest.Party != null && (isBot || Equals(toRequest.Party.PartyMaster, toRequest)))
{
if (await PartyRequestHandler.TryAutoAcceptPartyRequestAsync(toRequest, player).ConfigureAwait(false))
{
diff --git a/src/GameLogic/PlugIns/UnlockCharacterClass/UnlockCharacterAtLevelBase.cs b/src/GameLogic/PlugIns/UnlockCharacterClass/UnlockCharacterAtLevelBase.cs
index 0164cc55e..27b46e5c4 100644
--- a/src/GameLogic/PlugIns/UnlockCharacterClass/UnlockCharacterAtLevelBase.cs
+++ b/src/GameLogic/PlugIns/UnlockCharacterClass/UnlockCharacterAtLevelBase.cs
@@ -29,8 +29,13 @@ protected UnlockCharacterAtLevelBase(byte classNumber, int minimumLevel, string
///
public void CharacterLeveledUp(Player player)
{
+ // Server-side bots are excluded: they never create new characters, and animating several
+ // characters of one account concurrently (each with its own persistence context and thus its
+ // own stale copy of the account) lets two siblings pass the duplicate check below at the same
+ // time - both insert the same unlock row and the account's saves keep failing with a duplicate
+ // key of "PK_AccountCharacterClass" until the next restart.
if (player.Level >= this._minimumLevel
- && player.Account is { } account
+ && player.Account is { IsBot: false } account
&& account.UnlockedCharacterClasses.All(c => c.Number != this._classNumber))
{
var unlockedClass = player.GameContext.Configuration.CharacterClasses.FirstOrDefault(c => c.Number == this._classNumber);
diff --git a/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs b/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs
index e1dddb0d2..58ab6225f 100644
--- a/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs
+++ b/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs
@@ -155,6 +155,26 @@ public sealed class MuHelperSettings : IMuHelperSettings
/// Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used.
public bool FallbackBasicAttack { get; init; }
+ ///
+ /// Human MU Helper sessions drive their skills through the explicit client configuration, so this is always false.
+ public bool AutoSelectBestSkill => false;
+
+ ///
+ /// Human MU Helper sessions configure their buff slots explicitly, so this is always false.
+ public bool AutoSelectBuffs => false;
+
+ ///
+ /// The client-side MU Helper has no mana potion setting; this only exists for server-side bots.
+ public bool UseManaPotion => false;
+
+ ///
+ /// A human picked their hunting spot deliberately, so their offline session fights whatever is there.
+ public bool OnlyHuntSafeMonsters => false;
+
+ ///
+ /// Equipment progression is a server-side bot behavior only.
+ public bool PickUpgradeItems => false;
+
///
public override string ToString()
{
diff --git a/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.Designer.cs b/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.Designer.cs
new file mode 100644
index 000000000..70d2b2735
--- /dev/null
+++ b/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.Designer.cs
@@ -0,0 +1,5243 @@
+//
+using System;
+using MUnique.OpenMU.Persistence.EntityFramework;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
+{
+ [DbContext(typeof(EntityDataContext))]
+ [Migration("20260627120000_AddAccountIsBot")]
+ partial class AddAccountIsBot
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ChatBanUntil")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EMail")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsBot")
+ .HasColumnType("boolean");
+
+ b.Property("IsTemplate")
+ .HasColumnType("boolean");
+
+ b.Property("IsVaultExtended")
+ .HasColumnType("boolean");
+
+ b.Property("LanguageIsoCode")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(3)
+ .HasColumnType("character varying(3)")
+ .HasDefaultValue("en");
+
+ b.Property("LoginName")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RegistrationDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SecurityCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("State")
+ .HasColumnType("integer");
+
+ b.Property("TimeZone")
+ .HasColumnType("smallint");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid");
+
+ b.Property("VaultPassword")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LoginName")
+ .IsUnique();
+
+ b.HasIndex("VaultId")
+ .IsUnique();
+
+ b.ToTable("Account", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AccountCharacterClass", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("uuid");
+
+ b.Property("CharacterClassId")
+ .HasColumnType("uuid");
+
+ b.HasKey("AccountId", "CharacterClassId");
+
+ b.HasIndex("CharacterClassId");
+
+ b.ToTable("AccountCharacterClass", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CharacterClassId")
+ .HasColumnType("uuid");
+
+ b.Property("FullAncientSetEquipped")
+ .HasColumnType("boolean");
+
+ b.Property("Pose")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CharacterClassId");
+
+ b.ToTable("AppearanceData", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AreaSkillSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("DelayBetweenHits")
+ .HasColumnType("interval");
+
+ b.Property("DelayPerOneDistance")
+ .HasColumnType("interval");
+
+ b.Property("EffectRange")
+ .HasColumnType("integer");
+
+ b.Property("FrustumDistance")
+ .HasColumnType("real");
+
+ b.Property("FrustumEndWidth")
+ .HasColumnType("real");
+
+ b.Property("FrustumStartWidth")
+ .HasColumnType("real");
+
+ b.Property("HitChancePerDistanceMultiplier")
+ .HasColumnType("real");
+
+ b.Property("MaximumNumberOfHitsPerAttack")
+ .HasColumnType("integer");
+
+ b.Property("MaximumNumberOfHitsPerTarget")
+ .HasColumnType("integer");
+
+ b.Property("MinimumNumberOfHitsPerAttack")
+ .HasColumnType("integer");
+
+ b.Property("MinimumNumberOfHitsPerTarget")
+ .HasColumnType("integer");
+
+ b.Property("ProjectileCount")
+ .HasColumnType("integer");
+
+ b.Property("TargetAreaDiameter")
+ .HasColumnType("real");
+
+ b.Property("UseDeferredHits")
+ .HasColumnType("boolean");
+
+ b.Property("UseFrustumFilter")
+ .HasColumnType("boolean");
+
+ b.Property("UseTargetAreaFilter")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.ToTable("AreaSkillSettings", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Designation")
+ .HasColumnType("text");
+
+ b.Property("GameConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("MaximumValue")
+ .HasColumnType("real");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GameConfigurationId");
+
+ b.ToTable("AttributeDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRelationship", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AggregateType")
+ .HasColumnType("integer");
+
+ b.Property("CharacterClassId")
+ .HasColumnType("uuid");
+
+ b.Property("GameConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("InputAttributeId")
+ .HasColumnType("uuid");
+
+ b.Property("InputOperand")
+ .HasColumnType("real");
+
+ b.Property("InputOperator")
+ .HasColumnType("integer");
+
+ b.Property("OperandAttributeId")
+ .HasColumnType("uuid");
+
+ b.Property("PowerUpDefinitionValueId")
+ .HasColumnType("uuid");
+
+ b.Property("SkillId")
+ .HasColumnType("uuid");
+
+ b.Property("TargetAttributeId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CharacterClassId");
+
+ b.HasIndex("GameConfigurationId");
+
+ b.HasIndex("InputAttributeId");
+
+ b.HasIndex("OperandAttributeId");
+
+ b.HasIndex("PowerUpDefinitionValueId");
+
+ b.HasIndex("SkillId");
+
+ b.HasIndex("TargetAttributeId");
+
+ b.ToTable("AttributeRelationship", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRequirement", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AttributeId")
+ .HasColumnType("uuid");
+
+ b.Property("GameMapDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("ItemDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("MinimumValue")
+ .HasColumnType("integer");
+
+ b.Property("SkillId")
+ .HasColumnType("uuid");
+
+ b.Property("SkillId1")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AttributeId");
+
+ b.HasIndex("GameMapDefinitionId");
+
+ b.HasIndex("ItemDefinitionId");
+
+ b.HasIndex("SkillId");
+
+ b.HasIndex("SkillId1");
+
+ b.ToTable("AttributeRequirement", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("GroundId")
+ .HasColumnType("uuid");
+
+ b.Property("LeftGoalId")
+ .HasColumnType("uuid");
+
+ b.Property("LeftTeamSpawnPointX")
+ .HasColumnType("smallint");
+
+ b.Property("LeftTeamSpawnPointY")
+ .HasColumnType("smallint");
+
+ b.Property("RightGoalId")
+ .HasColumnType("uuid");
+
+ b.Property("RightTeamSpawnPointX")
+ .HasColumnType("smallint");
+
+ b.Property("RightTeamSpawnPointY")
+ .HasColumnType("smallint");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GroundId")
+ .IsUnique();
+
+ b.HasIndex("LeftGoalId")
+ .IsUnique();
+
+ b.HasIndex("RightGoalId")
+ .IsUnique();
+
+ b.ToTable("BattleZoneDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AccountId")
+ .HasColumnType("uuid");
+
+ b.Property("CharacterClassId")
+ .HasColumnType("uuid");
+
+ b.Property("CharacterSlot")
+ .HasColumnType("smallint");
+
+ b.Property("CharacterStatus")
+ .HasColumnType("integer");
+
+ b.Property("CreateDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CurrentMapId")
+ .HasColumnType("uuid");
+
+ b.Property("Experience")
+ .HasColumnType("bigint");
+
+ b.Property("InventoryExtensions")
+ .HasColumnType("integer");
+
+ b.Property("InventoryId")
+ .HasColumnType("uuid");
+
+ b.Property("IsStoreOpened")
+ .HasColumnType("boolean");
+
+ b.Property("KeyConfiguration")
+ .HasColumnType("bytea");
+
+ b.Property("LevelUpPoints")
+ .HasColumnType("integer");
+
+ b.Property("MasterExperience")
+ .HasColumnType("bigint");
+
+ b.Property("MasterLevelUpPoints")
+ .HasColumnType("integer");
+
+ b.Property("MuHelperConfiguration")
+ .HasColumnType("bytea");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("PlayerKillCount")
+ .HasColumnType("integer");
+
+ b.Property("Pose")
+ .HasColumnType("smallint");
+
+ b.Property("PositionX")
+ .HasColumnType("smallint");
+
+ b.Property("PositionY")
+ .HasColumnType("smallint");
+
+ b.Property("State")
+ .HasColumnType("integer");
+
+ b.Property("StateRemainingSeconds")
+ .HasColumnType("integer");
+
+ b.Property("StoreName")
+ .HasColumnType("text");
+
+ b.Property("UsedFruitPoints")
+ .HasColumnType("integer");
+
+ b.Property("UsedNegFruitPoints")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccountId");
+
+ b.HasIndex("CharacterClassId");
+
+ b.HasIndex("CurrentMapId");
+
+ b.HasIndex("InventoryId")
+ .IsUnique();
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Character", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CanGetCreated")
+ .HasColumnType("boolean");
+
+ b.Property("ComboDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("CreationAllowedFlag")
+ .HasColumnType("smallint");
+
+ b.Property("FruitCalculation")
+ .HasColumnType("integer");
+
+ b.Property("GameConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("HomeMapId")
+ .HasColumnType("uuid");
+
+ b.Property("IsMasterClass")
+ .HasColumnType("boolean");
+
+ b.Property("LevelRequirementByCreation")
+ .HasColumnType("smallint");
+
+ b.Property("LevelWarpRequirementReductionPercent")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("NextGenerationClassId")
+ .HasColumnType("uuid");
+
+ b.Property("Number")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ComboDefinitionId")
+ .IsUnique();
+
+ b.HasIndex("GameConfigurationId");
+
+ b.HasIndex("HomeMapId");
+
+ b.HasIndex("NextGenerationClassId");
+
+ b.ToTable("CharacterClass", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterDropItemGroup", b =>
+ {
+ b.Property("CharacterId")
+ .HasColumnType("uuid");
+
+ b.Property("DropItemGroupId")
+ .HasColumnType("uuid");
+
+ b.HasKey("CharacterId", "DropItemGroupId");
+
+ b.HasIndex("DropItemGroupId");
+
+ b.ToTable("CharacterDropItemGroup", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ActiveQuestId")
+ .HasColumnType("uuid");
+
+ b.Property("CharacterId")
+ .HasColumnType("uuid");
+
+ b.Property("ClientActionPerformed")
+ .HasColumnType("boolean");
+
+ b.Property("Group")
+ .HasColumnType("smallint");
+
+ b.Property("LastFinishedQuestId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActiveQuestId");
+
+ b.HasIndex("CharacterId");
+
+ b.HasIndex("LastFinishedQuestId");
+
+ b.ToTable("CharacterQuestState", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ClientCleanUpInterval")
+ .HasColumnType("interval");
+
+ b.Property("ClientTimeout")
+ .HasColumnType("interval");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MaximumConnections")
+ .HasColumnType("integer");
+
+ b.Property("RoomCleanUpInterval")
+ .HasColumnType("interval");
+
+ b.Property("ServerId")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.ToTable("ChatServerDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerEndpoint", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ChatServerDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("ClientId")
+ .HasColumnType("uuid");
+
+ b.Property("NetworkPort")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChatServerDefinitionId");
+
+ b.HasIndex("ClientId");
+
+ b.ToTable("ChatServerEndpoint", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CombinationBonusRequirement", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ItemOptionCombinationBonusId")
+ .HasColumnType("uuid");
+
+ b.Property("MinimumCount")
+ .HasColumnType("integer");
+
+ b.Property("OptionTypeId")
+ .HasColumnType("uuid");
+
+ b.Property("SubOptionType")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemOptionCombinationBonusId");
+
+ b.HasIndex("OptionTypeId");
+
+ b.ToTable("CombinationBonusRequirement", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConfigurationUpdate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("InstalledAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Version")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("ConfigurationUpdate", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConfigurationUpdateState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CurrentInstalledVersion")
+ .HasColumnType("integer");
+
+ b.Property("InitializationKey")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("ConfigurationUpdateState", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConnectServerDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CheckMaxConnectionsPerAddress")
+ .HasColumnType("boolean");
+
+ b.Property("ClientId")
+ .HasColumnType("uuid");
+
+ b.Property("ClientListenerPort")
+ .HasColumnType("integer");
+
+ b.Property("CurrentPatchVersion")
+ .HasColumnType("bytea");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("DisconnectOnUnknownPacket")
+ .HasColumnType("boolean");
+
+ b.Property("ListenerBacklog")
+ .HasColumnType("integer");
+
+ b.Property("MaxConnections")
+ .HasColumnType("integer");
+
+ b.Property("MaxConnectionsPerAddress")
+ .HasColumnType("integer");
+
+ b.Property("MaxFtpRequests")
+ .HasColumnType("integer");
+
+ b.Property("MaxIpRequests")
+ .HasColumnType("integer");
+
+ b.Property("MaxServerListRequests")
+ .HasColumnType("integer");
+
+ b.Property("MaximumReceiveSize")
+ .HasColumnType("smallint");
+
+ b.Property("PatchAddress")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ServerId")
+ .HasColumnType("smallint");
+
+ b.Property