Merge pull request #820 from nolt/feature-bots

(cherry picked from commit b10de0645a869485fbb5771a739abd06b5708c2d)
This commit is contained in:
sven-n
2026-07-16 22:01:57 +02:00
committed by Acentech Dev
parent 8baf329654
commit d04cbecae3
58 changed files with 14856 additions and 62 deletions

257
docs/Bots.md Normal file
View File

@@ -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.

View File

@@ -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

View File

@@ -130,6 +130,13 @@ public class Account
/// </summary>
public bool IsTemplate { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool IsBot { get; set; }
/// <summary>
/// Gets or sets the characters.
/// </summary>

View File

@@ -0,0 +1,122 @@
// <copyright file="BotConfiguration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// The admin-panel editable configuration of the <see cref="BotFeaturePlugIn"/>.
/// </summary>
public class BotConfiguration
{
/// <summary>
/// The hard limit of characters a single account can hold in the game.
/// </summary>
public const int MaxCharactersPerAccountLimit = 5;
/// <summary>
/// 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.
/// </summary>
[Display(Name = "Enabled", Description = "If enabled, bots are spawned after the server has started.")]
public bool Enabled { get; set; }
/// <summary>
/// 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.
/// </summary>
[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; }
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>
/// Gets or sets the share (in percent) of bots which stays online at the quietest time of day.
/// 100 effectively disables the rotation effect.
/// </summary>
[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;
/// <summary>
/// Gets or sets the number of bot accounts. Together with <see cref="MaxCharactersPerAccount"/>
/// this defines the generated bot population, e.g. 10 accounts × 5 characters = 50 bot characters.
/// </summary>
[Display(Name = "Number of accounts", Description = "How many bot accounts to maintain.")]
[Range(0, 1000)]
public int NumberOfAccounts { get; set; } = 10;
/// <summary>
/// Gets or sets the number of characters per bot account. An account can hold at most
/// <see cref="MaxCharactersPerAccountLimit"/> (5) characters, so this value is clamped on use.
/// </summary>
[Display(Name = "Characters per account", Description = "How many characters each bot account holds (max 5).")]
[Range(1, MaxCharactersPerAccountLimit)]
public int MaxCharactersPerAccount { get; set; } = MaxCharactersPerAccountLimit;
/// <summary>
/// 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
/// <see cref="BotServerPartition"/>); accounts which do not fit stay offline until the servers offer
/// the room for them.
/// </summary>
[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;
/// <summary>
/// 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).
/// </summary>
[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; }
/// <summary>
/// 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
/// <see cref="NumberOfAccounts"/>): 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.
/// </summary>
[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;
/// <summary>
/// Gets the effective, clamped number of characters per account.
/// </summary>
/// <returns>A value between 1 and <see cref="MaxCharactersPerAccountLimit"/>.</returns>
/// <remarks>Deliberately a method: a get-only property would end up in the serialized plugin configuration JSON.</remarks>
public int GetEffectiveCharactersPerAccount()
=> Math.Clamp(this.MaxCharactersPerAccount, 1, MaxCharactersPerAccountLimit);
/// <summary>
/// Gets the effective, clamped share of a server's player capacity which its bots may occupy.
/// </summary>
/// <returns>A value between 1 and 100.</returns>
/// <remarks>Deliberately a method, like <see cref="GetEffectiveCharactersPerAccount"/>.</remarks>
public int GetEffectiveBotCapacityPercent()
=> Math.Clamp(this.BotCapacityPercent, 1, 100);
/// <summary>
/// Parses <see cref="ProofOfConceptAccounts"/> into the distinct, trimmed login names.
/// </summary>
/// <returns>The list of login names.</returns>
/// <remarks>Deliberately a method: a get-only property would end up in the serialized plugin configuration JSON.</remarks>
public IReadOnlyList<string> ParseProofOfConceptAccounts()
=> this.ProofOfConceptAccounts
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}

View File

@@ -0,0 +1,283 @@
// <copyright file="BotEquipmentHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions.Items;
/// <summary>
/// Lets a bot progress its equipment like a real player: dropped gear is evaluated before pickup
/// (see <see cref="IsUpgradeFor"/>, used by the offline <see cref="ItemPickupHandler"/>), 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
/// <see cref="MoveItemAction"/>, which enforces the same class- and stat-requirements as for a human
/// player - including which hand a weapon needs (see <see cref="ItemExtensions.ConflictsWithEquippedHands"/>).
/// </summary>
internal static class BotEquipmentHandler
{
/// <summary>A candidate must beat the equipped piece by at least this score margin to be worth swapping.</summary>
private const int UpgradeScoreMargin = 1;
/// <summary>The highest item group which is a weapon (0 sword, 1 axe, 2 mace, 3 spear, 4 bow, 5 staff).</summary>
private const byte LastWeaponGroup = 5;
/// <summary>The item group of the shields.</summary>
private const byte ShieldGroup = 6;
private static readonly MoveItemAction MoveAction = new();
/// <summary>
/// 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.
/// </summary>
/// <param name="player">The bot player which would wear the item.</param>
/// <param name="item">The dropped item to evaluate.</param>
public static bool IsUpgradeFor(Player player, Item item)
{
return TryPlanSwap(player, item) is not null;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="player">The bot player whose backpack is scanned.</param>
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;
}
}
}
/// <summary>
/// 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 <see cref="MoveItemAction"/>.
/// 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.
/// </summary>
/// <returns><c>true</c> if the candidate is now equipped.</returns>
private static async ValueTask<bool> 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);
}
}
}
/// <summary>
/// Plans how the item could be worn: into which slot, and which equipped pieces it would replace.
/// Returns <c>null</c> 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
/// <see cref="IsUpgradeFor"/>. 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
/// <see cref="ItemExtensions.ConflictsWithEquippedHands"/>), and a bot which does not plan for that
/// keeps trying (and failing) to put it on forever.
/// </summary>
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<Item>(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;
}
/// <summary>
/// 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).
/// </summary>
private static IEnumerable<byte> 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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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).
/// </summary>
private sealed record EquipSwap(byte Slot, IReadOnlyList<Item> Removed);
}

View File

@@ -0,0 +1,576 @@
// <copyright file="BotFeaturePlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>
/// 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.
/// </summary>
[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<BotConfiguration>, ISupportDefaultCustomConfiguration
{
/// <summary>
/// Delay before the first spawn attempt, giving the server time to finish starting up
/// (maps, configuration and plugins fully initialized).
/// </summary>
private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15);
private static readonly TimeSpan MaintenanceInterval = TimeSpan.FromSeconds(60);
private static readonly TimeSpan PartyReformInterval = TimeSpan.FromMinutes(60);
/// <summary>
/// The typical activity of a player base by local hour (0..1): quietest in the early morning,
/// busiest in the evening. Scales between <see cref="BotConfiguration.MinOnlineSharePercent"/>
/// and 100% of the bot population.
/// </summary>
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,
];
/// <summary>
/// The state of the feature, per game server: the plugin instance is shared by all game servers of
/// the process, while <see cref="ExecuteTaskAsync"/> 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.
/// </summary>
private readonly ConcurrentDictionary<IGameContext, ServerState> _states = new();
/// <summary>
/// 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.
/// </summary>
private enum StartupPhase
{
/// <summary>The startup has not run yet.</summary>
NotStarted = 0,
/// <summary>The startup (generation and spawn) is in progress.</summary>
InProgress = 1,
/// <summary>The startup is done; the server is in maintenance mode.</summary>
Done = 2,
}
/// <inheritdoc />
public BotConfiguration? Configuration { get; set; }
/// <inheritdoc />
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.");
}
}
/// <inheritdoc />
public void ForceStart()
{
foreach (var state in this._states.Values)
{
state.NextRunUtc = DateTime.UtcNow;
}
}
/// <inheritdoc />
public object CreateDefaultConfig()
{
return CreateDefaultConfiguration();
}
private static BotConfiguration CreateDefaultConfiguration()
{
return new BotConfiguration();
}
/// <summary>
/// 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).
/// </summary>
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);
}
}
/// <summary>
/// Restarts bots whose AI keeps throwing (see <see cref="BotPlayer.AwaitsFaultRestart"/>). 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.
/// </summary>
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);
}
}
}
/// <summary>
/// Evolves bots which reached the game's maximum level into their master class (see
/// <see cref="BotMasterHandler"/> 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 <see cref="BotManager.RestartBotAsync"/>), which must not happen
/// from within one of its own timer callbacks.
/// </summary>
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;
}
});
}
}
}
/// <summary>
/// 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 <see cref="BotManager.RestartBotAsync"/>).
/// </summary>
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);
}
}
/// <summary>
/// Retries bringing back bots whose respawn after the master evolution failed.
/// </summary>
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);
}
}
}
/// <summary>
/// Rotates the presence of the bots THIS server animates (see <see cref="BotServerPartition"/>):
/// each server keeps the daily curve within its own share of the population.
/// </summary>
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);
}
}
}
/// <summary>
/// Persists the current configuration back to its <see cref="PlugInConfiguration"/> row, so a
/// programmatic change (e.g. clearing the reset flag) survives a restart.
/// </summary>
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<GameConfiguration>().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.");
}
}
/// <summary>
/// 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 <see cref="_states"/>).
/// </summary>
private sealed class ServerState
{
/// <summary>
/// The <see cref="StartupPhase"/> of this server, as a raw <see cref="int"/>: it is transitioned
/// with <see cref="Interlocked"/>, which has no overload for arbitrary enum types, so the field
/// stays an <see cref="int"/> and the phase constants are cast at the call sites.
/// </summary>
private int _startupState;
/// <summary>
/// 1 while a maintenance pass runs, so the next timer tick doesn't start a second one in
/// parallel. Interlocked-updated, hence a field.
/// </summary>
private int _maintenanceRunning;
/// <summary>
/// Gets the bots this server animates.
/// </summary>
public BotManager Manager { get; } = new();
/// <summary>
/// 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.
/// </summary>
public ConcurrentQueue<(string Login, byte Slot)> PendingRespawns { get; } = new();
/// <summary>
/// Gets or sets the share of the bot population which this server animates.
/// </summary>
public BotServerPartition? Partition { get; set; }
/// <summary>
/// Gets or sets the time of this server's (single) startup pass.
/// </summary>
public DateTime NextRunUtc { get; set; } = DateTime.UtcNow + StartupDelay;
/// <summary>
/// Gets or sets the time of this server's next maintenance pass.
/// </summary>
public DateTime NextMaintenanceUtc { get; set; } = DateTime.UtcNow + StartupDelay + StartupDelay;
/// <summary>
/// Gets or sets the time of this server's next bot party re-formation.
/// </summary>
public DateTime NextPartyReformUtc { get; set; } = DateTime.UtcNow + PartyReformInterval;
/// <summary>
/// Gets a reference to the startup state, for the interlocked transitions of the startup pass.
/// </summary>
public ref int StartupState => ref this._startupState;
/// <summary>
/// Gets a reference to the maintenance flag, for the interlocked guard of the maintenance pass.
/// </summary>
public ref int MaintenanceRunning => ref this._maintenanceRunning;
}
}

View File

@@ -0,0 +1,606 @@
// <copyright file="BotGenerator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>
/// Generates and maintains the persistent population of bot accounts and their characters.
/// </summary>
/// <remarks>
/// Accounts are flagged with <see cref="Account.IsBot"/> 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 (<see cref="GetLoginName"/>) which is never shown to other
/// players; the player-visible character names are realistic and unique (see <see cref="BotNameGenerator"/>).
/// Generation is idempotent: only the missing accounts are created, so it is safe to run on every start.
/// </remarks>
internal sealed class BotGenerator
{
private const string LoginPrefix = "bot";
/// <summary>
/// BCrypt work factor for a bot account's password. The password is a random <see cref="Guid"/>
/// which is discarded immediately and never used to log in - a bot is a connection-less
/// <c>OfflinePlayer</c>, 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.
/// </summary>
private const int BotPasswordWorkFactor = 4;
private const int MinLevel = 10;
/// <summary>
/// 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
/// (<see cref="BotProgression.ClassEvolutionLevel"/>) - those are created as their second-generation
/// class right away, like a player who did the class quest long ago.
/// </summary>
private const int MaxLevel = 250;
/// <summary>
/// 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).
/// </summary>
private const double LevelSkew = 1.6;
private const int StartMoney = 100000;
/// <summary>Upgrade level (+6) of the starter gear, giving fresh bots a survival buffer until they can warp.</summary>
private const byte StarterItemLevel = 6;
/// <summary>Number of inventory extensions (each 4 rows of 8 slots) a bot gets, so loot does not clog its backpack.</summary>
private const int BotInventoryExtensions = 4;
/// <summary>Highest item group that is a melee weapon (0 sword, 1 axe, 2 mace, 3 spear).</summary>
private const byte MaxMeleeGroup = 3;
/// <summary>Item group of bows (need ammunition).</summary>
private const byte BowGroup = 4;
/// <summary>Item group of staves/sticks (casters).</summary>
private const byte StaffGroup = 5;
/// <summary>Item group of body armor; its item number identifies the armor set.</summary>
private const byte ArmorGroup = 8;
/// <summary>
/// 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.
/// </summary>
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();
/// <summary>
/// Initializes a new instance of the <see cref="BotGenerator"/> class.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="logger">The logger.</param>
public BotGenerator(IGameContext gameContext, ILogger logger)
{
this._gameContext = gameContext;
this._logger = logger;
}
/// <summary>
/// Gets the deterministic, internal login name of the bot account with the given one-based index.
/// </summary>
/// <param name="index">The one-based account index.</param>
/// <returns>The login name, e.g. <c>bot0001</c> (kept within the 10 character account name limit).</returns>
public static string GetLoginName(int index) => $"{LoginPrefix}{index:D4}";
/// <summary>
/// Ensures that the configured number of bot accounts (each with the configured number of
/// characters) exists. Only missing accounts are created.
/// </summary>
/// <param name="numberOfAccounts">The desired number of bot accounts.</param>
/// <param name="charactersPerAccount">The desired number of characters per account.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of accounts that were newly created.</returns>
public async ValueTask<int> 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<string>(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>();
account.LoginName = loginName;
account.PasswordHash = BCrypt.Net.BCrypt.HashPassword(Guid.NewGuid().ToString(), BotPasswordWorkFactor);
account.IsBot = true;
account.Vault = context.CreateNew<ItemStorage>();
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;
}
/// <summary>
/// Deletes all bot accounts (and, by cascade, their characters and owned data).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of deleted bot accounts.</returns>
public async ValueTask<int> 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;
}
/// <summary>
/// Builds a shuffled queue of character classes with even quotas across <paramref name="classes"/>,
/// 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.
/// </summary>
private static Queue<CharacterClass> BuildBalancedClassQueue(IList<CharacterClass> classes, int total)
{
var pool = new List<CharacterClass>(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<CharacterClass>(pool);
}
/// <summary>
/// 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 <see cref="BotProgression"/>
/// 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.
/// </summary>
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;
}
}
}
/// <summary>
/// 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 <see cref="ResetProgressionCalculator"/>, so tiers,
/// multipliers and the replace/add mode of the server's configuration all apply) plus the points
/// earned by leveling. With <see cref="ResetConfiguration.ResetStats"/> 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.
/// </summary>
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>();
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<StatAttribute>(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<ItemStorage>();
character.Inventory.Money = StartMoney;
this.EquipStarterGear(context, character, resetConfiguration is not null);
account.Characters.Add(character);
}
/// <summary>
/// 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.
/// </summary>
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<short>(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<SkillEntry>();
entry.Skill = skill;
entry.Level = 0;
character.LearnedSkills.Add(entry);
}
}
/// <summary>
/// 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.
/// </summary>
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>();
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>();
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>();
item.Definition = arrows;
item.Durability = 255;
item.ItemSlot = InventoryConstants.LeftHandSlot;
inventory.Items.Add(item);
}
}

View File

@@ -0,0 +1,228 @@
// <copyright file="BotJewelHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>
/// 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 <see cref="ItemConsumeAction"/> - 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.
/// </summary>
internal static class BotJewelHandler
{
/// <summary>Upper bound of jewel consumptions per shopping trip - a player doesn't burn the whole hoard at once.</summary>
private const int MaxUsesPerTrip = 2;
/// <summary>The Jewel of Bless upgrades item levels 0..5 (see <c>BlessJewelConsumeHandlerPlugIn</c>).</summary>
private const byte BlessMaxTargetLevel = 5;
/// <summary>Souls are never spent below +6 - that range is Bless territory (safe and cheap).</summary>
private const byte SoulMinTargetLevel = 6;
/// <summary>
/// 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
/// (<c>ResetToLevel0WhenFailMinLevel</c> in <c>SoulJewelConsumeHandlerPlugIn</c>), and at the base
/// 50% success rate that gamble wipes gear more often than not.
/// </summary>
private const byte SoulMaxTargetLevelPlain = 6;
/// <summary>
/// With luck (+25% success) the Soul may be risked up to +8, so lucky items can reach the jewel
/// ceiling of +9 (<c>MaximumLevel</c> in <c>SoulJewelConsumeHandlerPlugIn</c>).
/// </summary>
private const byte SoulMaxTargetLevelLucky = 8;
/// <summary>Only risk a Soul with at least this many in stock - one failure must not wipe out the reserve.</summary>
private const int MinSoulStock = 2;
/// <summary>Life is only worth risking on gear which already proved worth upgrading.</summary>
private const byte LifeMinTargetLevel = 6;
/// <summary>Only risk a Life with at least this many in stock.</summary>
private const int MinLifeStock = 2;
private static readonly ItemConsumeAction ConsumeAction = new();
private static readonly MoveItemAction MoveAction = new();
/// <summary>
/// Spends up to <see cref="MaxUsesPerTrip"/> 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 <c>EnteredWorld</c>, which the consume handlers require), and the
/// navigator's shopping cooldown provides the rare, player-like cadence.
/// </summary>
/// <param name="player">The bot player.</param>
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);
}
}
}
/// <summary>
/// Picks the next (jewel, equipped target item) pair according to the player-like policy, or
/// <c>null</c> when nothing sensible is left to do. Pure decision logic - exposed for unit tests.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="lifeUsed">Whether a Jewel of Life was already used this trip (at most one).</param>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <returns><c>true</c>, if the jewel was actually consumed.</returns>
private static async ValueTask<bool> 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);
}
}

View File

@@ -0,0 +1,310 @@
// <copyright file="BotManager.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Collections.Concurrent;
using System.Linq;
using MUnique.OpenMU.GameLogic.Offline;
/// <summary>
/// Manages the lifecycle of server-side bots.
/// </summary>
/// <remarks>
/// A bot reuses the connection-less <see cref="OfflinePlayer"/> 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 <see cref="OfflinePlayer.InitializeAsync"/>), 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 <c>/offlevel</c> 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).
/// </remarks>
public sealed class BotManager
{
private readonly ConcurrentDictionary<string, BotPlayer> _bots = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets a snapshot of the currently active bots.
/// </summary>
public IReadOnlyCollection<BotPlayer> Bots => this._bots.Values.ToList();
/// <summary>
/// Spawns a bot which drives a specific character of the given account.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="loginName">The login name of an existing account.</param>
/// <param name="characterSlot">The character slot to drive; <c>null</c> drives the first character by slot.</param>
/// <returns><c>true</c> if a bot was started; <c>false</c> if it could not be started or was already active.</returns>
public async ValueTask<bool> 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;
}
}
/// <summary>
/// Stops and removes all currently active bots.
/// </summary>
/// <returns>The task.</returns>
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);
}
}
}
/// <summary>
/// Determines whether the given character of the given account is currently animated by a bot.
/// </summary>
/// <param name="loginName">The account login name.</param>
/// <param name="slot">The character slot.</param>
public bool IsActive(string loginName, byte slot) => this._bots.ContainsKey(GetKey(loginName, slot));
/// <summary>
/// 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.
/// </summary>
/// <returns>The name of the stopped bot's character, or null if no bot was active.</returns>
public async ValueTask<string?> 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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="bot">The bot to restart.</param>
/// <returns><c>true</c> if the bot came back online.</returns>
public async ValueTask<bool> 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);
}
/// <summary>
/// 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
/// <see cref="BotNavigator"/>), 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.
/// </summary>
/// <param name="gameContext">The game context (provides the party manager).</param>
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<BotPlayer> { 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}";
/// <summary>
/// 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 <see cref="Offline.OfflinePlayerManager"/>; the removal
/// from the game context happens through the disconnect event.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,351 @@
// <copyright file="BotMasterHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>
/// 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 <see cref="AddMasterPointAction"/> with all its validations.
/// </summary>
/// <remarks>
/// 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 <c>BotManager.RestartBotAsync</c>) - the ghost
/// equivalent of a player relogging; master experience only flows after that fresh world entry.
/// </remarks>
internal static class BotMasterHandler
{
/// <summary>
/// A master skill unlocks the next rank of its root (and satisfies "required skill" links) at this
/// level, see <see cref="AddMasterPointAction"/>.
/// </summary>
private const int RankUnlockLevel = 10;
/// <summary>The item groups of the weapons a master bonus can be tied to (see <see cref="WeaponGroupOfBonus"/>).</summary>
private const byte SwordGroup = 0;
/// <summary>The item group of the maces - the scepters live here as well.</summary>
private const byte MaceGroup = 2;
/// <summary>The item group of the spears.</summary>
private const byte SpearGroup = 3;
/// <summary>The item group of the bows and crossbows.</summary>
private const byte BowGroup = 4;
/// <summary>The item group of the staffs - the sticks and books live here as well.</summary>
private const byte StaffGroup = 5;
private static readonly AddMasterPointAction AddPointAction = new();
/// <summary>
/// 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 <see cref="BotMasterHandler"/> for the rationale).
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns>True, if the bot should evolve into its master class now.</returns>
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;
}
/// <summary>
/// Evolves the bot into its master class when due. The caller must restart the bot afterwards
/// (see the remarks of <see cref="BotMasterHandler"/>).
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns>True, if the evolution was performed.</returns>
public static async ValueTask<bool> 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;
}
/// <summary>
/// Determines whether the bot is a master with points to invest - the cheap per-tick guard for
/// queueing <see cref="TrySpendMasterPointsAsync"/>.
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns>True, if there are master points to spend.</returns>
public static bool HasMasterPointsToSpend(Player player)
=> player.SelectedCharacter is { CharacterClass.IsMasterClass: true, MasterLevelUpPoints: > 0 };
/// <summary>
/// Invests the bot's available master points (earned one per master level) into its master skill
/// tree through the regular <see cref="AddMasterPointAction"/>. Learning a skill mutates the
/// skill list, so this must run inside the bot's AI tick - queue it via
/// <see cref="OfflinePlayer.PendingBotActions"/> (see the call site in <c>BotNavigator</c>);
/// with no points available it is a cheap no-op.
/// </summary>
/// <param name="player">The bot player.</param>
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);
}
}
/// <summary>
/// Picks the master skill the bot invests its next point into, or <c>null</c> 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.
/// </summary>
/// <param name="player">The bot player.</param>
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;
}
/// <summary>
/// Mirrors the private requisition checks of <see cref="AddMasterPointAction"/> (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.
/// </summary>
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;
}
/// <summary>
/// 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
/// <see cref="BotPvpRules"/>). Both get filled last, after everything which actually helps.
/// </summary>
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);
}
/// <summary>
/// Whether the master bonus only ever applies in a fight against another player, which is not what a
/// bot's points are for.
/// </summary>
/// <param name="attribute">The bonus attribute.</param>
private static bool IsPvpOnlyBonus(AttributeDefinition attribute)
{
return attribute == Stats.AttackRatePvp || attribute == Stats.DefenseRatePvp;
}
/// <summary>
/// The item group of the weapon a master bonus attribute belongs to, or <c>null</c> 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.
/// </summary>
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;
}
/// <summary>
/// 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 <see cref="BotProgression.IsPreferredWeaponGroup"/>), so
/// a bot which is momentarily unarmed does not start collecting bonuses for the wrong weapon.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,177 @@
// <copyright file="BotMiniGameHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>
/// 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
/// <see cref="BotNavigator"/>; 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.
/// </summary>
internal static class BotMiniGameHandler
{
/// <summary>
/// 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.
/// </summary>
/// <param name="player">The player about to enter a mini game.</param>
/// <returns>The party bots to bring along; empty when the player is a bot itself, has no party or is not its master.</returns>
internal static IReadOnlyList<OfflinePlayer> SnapshotPartyBots(Player player)
{
if (player is OfflinePlayer
|| player.Party is not { } party
|| !ReferenceEquals(party.PartyMaster, player))
{
return [];
}
return party.PartyList
.OfType<OfflinePlayer>()
.Where(bot => bot.Account?.IsBot == true)
.ToList();
}
/// <summary>
/// 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 <see cref="OfflinePlayer.PendingBotActions"/>),
/// because warping and effect-clearing mutate the bot's state.
/// </summary>
/// <param name="leader">The party leader who entered the mini game.</param>
/// <param name="bots">The snapshot taken by <see cref="SnapshotPartyBots"/> before the entry.</param>
/// <param name="definition">The definition of the entered mini game.</param>
/// <param name="miniGame">The mini game instance the leader entered.</param>
internal static void BringPartyBotsAlong(Player leader, IReadOnlyList<OfflinePlayer> bots, MiniGameDefinition definition, MiniGameContext miniGame)
{
foreach (var bot in bots)
{
bot.PendingBotActions.Enqueue(() => TryEnterAsync(bot, leader, definition, miniGame));
}
}
/// <summary>
/// Determines whether the bot passes the same entry restrictions <c>EnterMiniGameAction</c>
/// 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.
/// </summary>
/// <param name="bot">The bot which wants to follow its leader in.</param>
/// <param name="definition">The mini game definition.</param>
/// <param name="reason">The human-readable reason when the bot does not qualify.</param>
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);
}
}

View File

@@ -0,0 +1,212 @@
// <copyright file="BotMuHelperSettings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.MuHelper;
/// <summary>
/// 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 <see cref="Offline.CombatHandler"/>).
/// These defaults make the bot hunt nearby monsters, pick up the valuable drops and use
/// potions, while staying close to its spawn origin.
/// </summary>
internal sealed class BotMuHelperSettings : IMuHelperSettings
{
/// <inheritdoc />
public int BasicSkillId => 0;
/// <inheritdoc />
public int ActivationSkill1Id => 0;
/// <inheritdoc />
public int ActivationSkill2Id => 0;
/// <inheritdoc />
public int DelayMinSkill1 => 0;
/// <inheritdoc />
public int DelayMinSkill2 => 0;
/// <inheritdoc />
public bool Skill1UseTimer => false;
/// <inheritdoc />
public bool Skill1UseCondition => false;
/// <inheritdoc />
public bool Skill1ConditionAttacking => false;
/// <inheritdoc />
public int Skill1SubCondition => 0;
/// <inheritdoc />
public bool Skill2UseTimer => false;
/// <inheritdoc />
public bool Skill2UseCondition => false;
/// <inheritdoc />
public bool Skill2ConditionAttacking => false;
/// <inheritdoc />
public int Skill2SubCondition => 0;
/// <inheritdoc />
public bool UseCombo => false;
/// <inheritdoc />
public int HuntingRange => 6;
/// <inheritdoc />
public int MaxSecondsAway => 30;
/// <inheritdoc />
public bool LongRangeCounterAttack => false;
/// <inheritdoc />
/// <remarks>
/// Disabled for bots: the <see cref="BotNavigator"/> 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.
/// </remarks>
public bool ReturnToOriginalPosition => false;
/// <inheritdoc />
public int BuffSkill0Id => 0;
/// <inheritdoc />
public int BuffSkill1Id => 0;
/// <inheritdoc />
public int BuffSkill2Id => 0;
/// <inheritdoc />
public bool BuffOnDuration => false;
/// <inheritdoc />
public bool BuffDurationForParty => false;
/// <inheritdoc />
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.
/// <inheritdoc />
public bool AutoHeal => true;
/// <inheritdoc />
public int HealThresholdPercent => 60;
/// <inheritdoc />
public bool UseDrainLife => false;
/// <inheritdoc />
public bool UseHealPotion => true;
/// <inheritdoc />
public int PotionThresholdPercent => 60;
/// <inheritdoc />
// 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;
/// <inheritdoc />
public bool AutoHealParty => true;
/// <inheritdoc />
public int HealPartyThresholdPercent => 60;
/// <inheritdoc />
public bool UseDarkRaven => false;
/// <inheritdoc />
public int DarkRavenMode => 0;
/// <inheritdoc />
public int ObtainRange => 6;
/// <inheritdoc />
public bool PickAllItems => false;
/// <inheritdoc />
// 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;
/// <inheritdoc />
public bool PickJewel => true;
/// <inheritdoc />
public bool PickZen => true;
/// <inheritdoc />
public bool PickAncient => true;
/// <inheritdoc />
public bool PickExcellent => true;
/// <inheritdoc />
public bool PickExtraItems => false;
/// <inheritdoc />
public IReadOnlyList<string> ExtraItemNames => Array.Empty<string>();
/// <inheritdoc />
/// <remarks>
/// 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.
/// </remarks>
public bool RepairItem => false;
/// <inheritdoc />
/// <remarks>Bots fight back when a player attacks them (see <see cref="BotSelfDefensePlugIn"/>).</remarks>
public bool UseSelfDefense => true;
/// <inheritdoc />
public bool AutoAcceptFriend => false;
/// <inheritdoc />
public bool AutoAcceptGuild => false;
/// <inheritdoc />
/// <remarks>
/// Bots accept party invitations from any player, like a friendly stranger would - within the
/// safeguards applied by <see cref="BotPartyHandler"/> (level gap, not while busy, limited time).
/// </remarks>
public bool AutoAcceptAnyone => true;
/// <inheritdoc />
public bool FallbackBasicAttack => true;
/// <inheritdoc />
/// <remarks>
/// 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 <see cref="BotGenerator"/>), this makes bots cast class- and
/// level-appropriate magic/skills instead of only swinging their weapon.
/// </remarks>
public bool AutoSelectBestSkill => true;
/// <inheritdoc />
/// <remarks>Bots keep their class's learned buffs up automatically (e.g. elf Greater Defense/Greater Damage).</remarks>
public bool AutoSelectBuffs => true;
/// <inheritdoc />
/// <remarks>Casters drink mana potions, so they keep casting instead of degrading to weak melee.</remarks>
public bool UseManaPotion => true;
/// <inheritdoc />
/// <remarks>
/// 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.
/// </remarks>
public bool OnlyHuntSafeMonsters => true;
/// <inheritdoc />
/// <remarks>Bots evaluate dropped gear and pick up upgrades for their own class (see <see cref="BotEquipmentHandler"/>).</remarks>
public bool PickUpgradeItems => true;
}

View File

@@ -0,0 +1,92 @@
// <copyright file="BotNameGenerator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Globalization;
using System.Threading;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Generates pronounceable, realistic-looking character names for bots.
/// </summary>
/// <remarks>
/// 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 <see cref="DataModel.Entities.Account.IsBot"/>, never by the name.
/// </remarks>
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();
/// <summary>
/// Generates a name which is not yet used, neither within this run nor in the database.
/// </summary>
/// <param name="context">The context used to check name availability.</param>
/// <param name="reserved">The set of names already handed out in this run; the returned name is added to it.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A unique, valid character name.</returns>
public async ValueTask<string> GenerateUniqueAsync(IPlayerContext context, ISet<string> 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();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,222 @@
// <copyright file="BotPartyHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.Offline;
/// <summary>
/// Lets a server-side bot party up with players who invite it (enabled by
/// <see cref="BotMuHelperSettings.AutoAcceptAnyone"/>): 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 <see cref="BotNavigator"/>) 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.
/// </summary>
internal static class BotPartyHandler
{
/// <summary>
/// The maximum difference of the reset-aware effective level (see
/// <see cref="BotResetHandler.GetEffectiveLevel"/>) 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.
/// </summary>
private const int MaxEffectiveLevelGap = 500;
/// <summary>Lower bound of the human-like delay before the bot answers an invitation.</summary>
private static readonly TimeSpan MinAcceptDelay = TimeSpan.FromSeconds(2);
/// <summary>Upper bound of the human-like delay before the bot answers an invitation.</summary>
private static readonly TimeSpan MaxAcceptDelay = TimeSpan.FromSeconds(5);
/// <summary>
/// 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).
/// </summary>
private static readonly TimeSpan MinPartyDuration = TimeSpan.FromMinutes(10);
/// <summary>Upper bound of the time the bot stays in a party with a human, see <see cref="MinPartyDuration"/>.</summary>
private static readonly TimeSpan MaxPartyDuration = TimeSpan.FromMinutes(20);
/// <summary>
/// Schedules the acceptance of a party invitation to a bot, if the bot is available for it.
/// Called from the auto-accept criteria of <see cref="MuHelper.PartyRequestHandler"/>.
/// </summary>
/// <param name="receiver">The invited player; only server-side bots schedule an accept.</param>
/// <param name="requester">The player who sent the party request.</param>
/// <param name="acceptDelay">Overrides the human-like random delay (used by tests).</param>
/// <returns>True, if the invitation was taken and will be answered; false, if no criteria matched.</returns>
internal static async ValueTask<bool> 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;
}
/// <summary>
/// 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 <see cref="BotManager"/>).
/// </summary>
/// <param name="bot">The bot.</param>
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;
}
}
/// <summary>
/// Determines whether the bot's party contains a human player (any live member which is not a
/// server-side <see cref="OfflinePlayer"/>).
/// </summary>
/// <param name="bot">The bot.</param>
/// <returns>True, if a human player is in the bot's party.</returns>
internal static bool HasHumanCompanion(Player bot)
{
return bot.Party is { } party
&& party.PartyList.OfType<Player>().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);
}
}
/// <summary>
/// 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 <see cref="BotManager"/>).
/// </summary>
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;
}
}

View File

@@ -0,0 +1,115 @@
// <copyright file="BotPlayer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Threading;
using MUnique.OpenMU.GameLogic.Offline;
/// <summary>
/// A connection-less bot player. It reuses the whole offline-player intelligence (combat, buffs,
/// healing, pickup) and adds a <see cref="BotNavigator"/> which makes it roam to level-appropriate
/// hunting grounds instead of standing on a fixed spawn position.
/// </summary>
public sealed class BotPlayer : OfflinePlayer
{
/// <summary>
/// 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.
/// </summary>
private const int ConsecutiveFailuresUntilRestart = 20;
private BotNavigator? _navigator;
private int _consecutiveTickFailures;
/// <summary>
/// Initializes a new instance of the <see cref="BotPlayer"/> class.
/// </summary>
/// <param name="gameContext">The game context.</param>
public BotPlayer(IGameContext gameContext)
: base(gameContext)
{
}
/// <inheritdoc />
public override bool RespawnAndContinue => true;
/// <summary>
/// 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 <see cref="BotManager.RestartBotAsync"/>).
/// 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).
/// </summary>
public bool AwaitsMasterRestart { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this bot's AI keeps failing and it has to be restarted
/// (see <see cref="ConsecutiveFailuresUntilRestart"/>). Acted upon by the maintenance pass, which is
/// the only place allowed to restart a bot.
/// </summary>
public bool AwaitsFaultRestart { get; set; }
/// <inheritdoc />
public override async ValueTask StopAsync()
{
await this.StopNavigatorAsync().ConfigureAwait(false);
await base.StopAsync().ConfigureAwait(false);
}
/// <inheritdoc />
internal override void OnAiTickSucceeded()
{
if (this._consecutiveTickFailures > 0)
{
Interlocked.Exchange(ref this._consecutiveTickFailures, 0);
}
}
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
protected override void StartIntelligence()
{
base.StartIntelligence();
this._navigator = new BotNavigator(this);
this._navigator.Start();
}
/// <inheritdoc />
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);
}
}
}

View File

@@ -0,0 +1,433 @@
// <copyright file="BotProgression.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.Attributes;
/// <summary>
/// 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 <see cref="BotGenerator"/> when a bot is
/// created and by the <see cref="BotSkillProgressionPlugIn"/> 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.
/// </summary>
internal static class BotProgression
{
/// <summary>
/// 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 <c>QuestCompletionAction</c>), so bots take the direct route.
/// </summary>
public const int ClassEvolutionLevel = 200;
/// <summary>
/// The character class numbers from the game's data model (<c>CharacterClassNumber</c> lives in the
/// initialization assembly which GameLogic does not reference, so the relevant values are mirrored here).
/// </summary>
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;
/// <summary>
/// The share of each invested batch that goes into vitality on reset-meta servers, until the bot's
/// personal <see cref="GetVitalityTarget"/> is reached (out of a nominal weight total of ~100).
/// </summary>
private const int ResetMetaVitalityWeight = 5;
/// <summary>
/// The base classes which evolve into a second-generation class at <see cref="ClassEvolutionLevel"/>:
/// 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.
/// </summary>
private static readonly byte[] EvolvableClassNumbers = [0, 4, 8, 20];
/// <summary>
/// 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.
/// </summary>
private static readonly short[] ExcludedBuffSkillNumbers = [18, 219, 221, 222];
/// <summary>
/// Gets the class the character evolves into at <see cref="ClassEvolutionLevel"/>, or null when the
/// class has no (in-scope) evolution.
/// </summary>
/// <param name="characterClass">The character class.</param>
public static CharacterClass? GetEvolutionTarget(CharacterClass characterClass)
{
return EvolvableClassNumbers.Contains(characterClass.Number)
? characterClass.NextGenerationClass
: null;
}
/// <summary>
/// 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 <see cref="GetEvolutionTarget"/> 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 <see cref="BotMasterHandler.IsMasterEvolutionDue"/>.
/// </summary>
/// <param name="characterClass">The character class.</param>
public static CharacterClass? GetMasterEvolutionTarget(CharacterClass characterClass)
{
return characterClass is { IsMasterClass: false, NextGenerationClass: { IsMasterClass: true } masterClass }
? masterClass
: null;
}
/// <summary>
/// How a bot invests its stat points, per class and per bot, in one of two meta profiles chosen
/// by the server type (see <see cref="BotResetHandler.GetResetConfiguration"/> at the call sites):
/// <list type="bullet">
/// <item><b>Reset meta</b> (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
/// <see cref="GetVitalityTarget"/> is reached (enforce via <see cref="SplitPoints"/> capacities).</item>
/// <item><b>Classic meta</b> (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.</item>
/// </list>
/// 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.
/// </summary>
/// <param name="characterClass">The character class.</param>
/// <param name="characterName">The character name; decides the build variant for two-build classes.</param>
/// <param name="resetMeta">Whether the reset-server meta profile applies.</param>
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) },
};
}
/// <summary>
/// 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.
/// </summary>
/// <param name="characterName">The character name.</param>
public static int GetVitalityTarget(string characterName)
{
var sum = characterName.Aggregate(0, (acc, c) => acc + c);
return 100 + ((sum * 7919) % 401);
}
/// <summary>
/// Splits the given points proportionally to the class's stat weights, returning whole-point
/// amounts which sum up to <paramref name="points"/> - unless capacities cut it short. The
/// optional <paramref name="capacityOf"/> callback limits how many points a stat may still take
/// (its <see cref="AttributeDefinition.MaximumValue"/> 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.
/// </summary>
/// <param name="points">The number of points to split.</param>
/// <param name="weights">The stat weights of the class.</param>
/// <param name="capacityOf">Optionally resolves how many more points a stat can take; null means unlimited.</param>
public static IEnumerable<(AttributeDefinition Stat, int Amount)> SplitPoints(
int points,
IReadOnlyList<(AttributeDefinition Stat, int Weight)> weights,
Func<AttributeDefinition, long>? 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]);
}
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="skill">The skill to check.</param>
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);
}
/// <summary>
/// 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).
/// <paramref name="getAttributeValue"/> resolves an attribute's current value; returning null means
/// the attribute is unknown in the caller's context, which conservatively fails the requirement.
/// </summary>
/// <param name="skill">The skill whose requirements are checked.</param>
/// <param name="getAttributeValue">Resolves an attribute's current value; null means the attribute is unknown.</param>
public static bool MeetsRequirements(Skill skill, Func<AttributeDefinition, float?> 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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="attribute">The "total" attribute to map.</param>
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;
}
/// <summary>
/// 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 <see cref="GetStatWeights"/>)
/// 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 (<see cref="BotGenerator"/>) and for later upgrades
/// (<see cref="BotEquipmentHandler"/>), so an elf never swaps its bow for a random axe it happens to
/// be qualified for (which would also displace its arrows).
/// </summary>
/// <param name="characterClass">The character class.</param>
/// <param name="characterName">The character name; decides the build variant, see <see cref="GetStatWeights"/>.</param>
/// <param name="resetMeta">Whether the reset-server meta profile applies.</param>
/// <param name="itemGroup">The item group of the weapon.</param>
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;
}
}

View File

@@ -0,0 +1,69 @@
// <copyright file="BotPvpRules.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
/// <summary>
/// The single source of truth for when a server-side bot may attack a player.
/// </summary>
/// <remarks>
/// Invariant: a bot must never escalate its own <see cref="HeroState"/>. 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
/// <c>Player.AfterKilledPlayerAsync</c>) 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 (<see cref="Offline.OfflinePlayer.RecentAggressor"/>, ~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.
/// </remarks>
public static class BotPvpRules
{
/// <summary>
/// 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.
/// </summary>
private static readonly TimeSpan SelfDefenseSafetyMargin = TimeSpan.FromSeconds(3);
/// <summary>
/// Determines whether the bot may legally attack the given player, i.e. without any risk of
/// escalating the bot's own <see cref="HeroState"/>.
/// </summary>
/// <param name="bot">The bot (or offline player) which wants to attack.</param>
/// <param name="target">The player it wants to attack.</param>
/// <returns><c>true</c> if attacking is free of PK consequences; otherwise, <c>false</c>.</returns>
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;
}
}

View File

@@ -0,0 +1,218 @@
// <copyright file="BotResetHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>
/// Performs character resets for bots on servers where the <see cref="ResetFeaturePlugIn"/> is enabled,
/// and provides the reset-aware effective level used by the bot logic. Everything is driven by the
/// server's actual <see cref="ResetConfiguration"/> (and <see cref="ResetProgressionCalculator"/>), 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.
/// </summary>
/// <remarks>
/// The reset itself mirrors the effect of <see cref="ResetCharacterAction"/>, 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
/// <see cref="BotConfiguration.BotsPayResetCosts"/>), and the <see cref="ResetConfiguration.LogOut"/>
/// step is replaced by continuing in place - a bot has no client to log out.
/// </remarks>
internal static class BotResetHandler
{
/// <summary>
/// Gets the server's reset configuration, or null when the reset feature is not enabled.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <returns>The reset configuration, or null.</returns>
public static ResetConfiguration? GetResetConfiguration(IGameContext gameContext)
=> gameContext.FeaturePlugIns.GetPlugIn<ResetFeaturePlugIn>()?.Configuration;
/// <summary>
/// Gets the player's effective level for bot decisions: on a reset server a freshly reset
/// character is back at the configured <see cref="ResetConfiguration.LevelAfterReset"/> 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 (<see cref="ResetConfiguration.RequiredLevel"/>).
/// 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.
/// </summary>
/// <param name="player">The player.</param>
/// <returns>The effective level.</returns>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="configuration">The reset configuration.</param>
/// <returns>True, if the bot can reset now.</returns>
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;
}
/// <summary>
/// Resets the bot character, mirroring the effect of <see cref="ResetCharacterAction"/>: the reset
/// count goes up, the level drops to <see cref="ResetConfiguration.LevelAfterReset"/>, the stats and
/// level-up points follow the server's configuration, and with <see cref="ResetConfiguration.MoveHome"/>
/// 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.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="configuration">The reset configuration.</param>
/// <param name="payCosts">Whether the configured costs (zen, reset items) are consumed like for a human player.</param>
/// <returns>True, if the reset was performed.</returns>
public static async ValueTask<bool> 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<ICharacterLevelUpPlugIn>()?.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;
}
/// <summary>
/// Consumes the configured reset costs like <see cref="ResetCharacterAction"/> does for a human
/// player: the required zen and the required amount of the configured reset item. Only used when
/// <see cref="BotConfiguration.BotsPayResetCosts"/> is enabled.
/// </summary>
private static async ValueTask<bool> TryConsumeCostsAsync(OfflinePlayer player, ResetConfiguration configuration, ResetProgression resetProgression)
{
IList<Item> 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;
}
/// <summary>
/// Warps the bot to a spawn gate of its class home map, the live-ghost equivalent of the
/// position rewrite <see cref="ResetCharacterAction"/> performs before logging a player out.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,40 @@
// <copyright file="BotRevengePlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// 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 <see cref="BotNavigator"/>; the counter-attack in
/// the offline <see cref="CombatHandler"/>, whose re-armed aggressor memory keeps the killer
/// prioritized - struck only once the game's own rules make it legal (see <see cref="BotPvpRules"/>).
/// </summary>
[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
{
/// <inheritdoc />
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;
}
}

View File

@@ -0,0 +1,36 @@
// <copyright file="BotSelfDefensePlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// 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
/// <see cref="CombatHandler"/>, which prioritizes a recent aggressor over its monster targets.
/// </summary>
[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
{
/// <inheritdoc />
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);
}
}
}

View File

@@ -0,0 +1,186 @@
// <copyright file="BotServerPartition.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// 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.
/// <para>
/// 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
/// (<see cref="BotConfiguration.BotCapacityPercent"/>) is handed to the bots: the rest stays reserved
/// for real players, who must never be denied a slot by a bot.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
internal sealed class BotServerPartition
{
private BotServerPartition(int firstAccount, int accountCount, bool isGenerator)
{
this.FirstAccount = firstAccount;
this.AccountCount = accountCount;
this.IsGenerator = isGenerator;
}
/// <summary>
/// Gets the one-based index of the first bot account this server animates.
/// </summary>
public int FirstAccount { get; }
/// <summary>
/// Gets the number of bot accounts this server animates.
/// </summary>
public int AccountCount { get; }
/// <summary>
/// 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.
/// </summary>
public bool IsGenerator { get; }
/// <summary>
/// Determines the share of the bot population which the given game server animates.
/// </summary>
/// <param name="gameContext">The context of the game server which asks.</param>
/// <param name="configuration">The bot configuration.</param>
/// <param name="logger">The logger.</param>
/// <returns>The share of this server.</returns>
public static async ValueTask<BotServerPartition> 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;
}
/// <summary>
/// Determines whether this server animates the bot account with the given one-based index.
/// </summary>
/// <param name="accountIndex">The one-based bot account index.</param>
public bool Owns(int accountIndex)
=> accountIndex >= this.FirstAccount && accountIndex < this.FirstAccount + this.AccountCount;
/// <summary>
/// Hands the accounts to the servers, each getting a share PROPORTIONAL to its capacity: the pure
/// decision behind <see cref="CreateAsync"/>. Every server runs it over the same list and gets the
/// same answer, which is what makes the split need no coordination at all.
/// <para>
/// 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.
/// </para>
/// </summary>
/// <param name="capacities">How many accounts each game server may animate, ordered by server id.</param>
/// <param name="serverId">The id of the server which asks.</param>
/// <param name="requestedAccounts">The configured number of bot accounts.</param>
/// <returns>The share of the asking server, and how many accounts fit into the deployment at all.</returns>
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);
}
/// <summary>
/// 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.
/// </summary>
private static async ValueTask<List<(byte ServerId, int Capacity)>> GetAccountCapacitiesAsync(
IGameContext gameContext,
BotConfiguration configuration,
int charactersPerAccount,
ILogger logger)
{
try
{
using var context = gameContext.PersistenceContextProvider.CreateNewConfigurationContext();
var definitions = await context.GetAsync<GameServerDefinition>().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 [];
}
}
}

View File

@@ -0,0 +1,245 @@
// <copyright file="BotShoppingHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>
/// 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 <see cref="BotNavigator"/>). The trade uses the regular player
/// actions (<see cref="TalkNpcAction"/>, <see cref="SellItemToNpcAction"/>, <see cref="BuyNpcItemAction"/>),
/// and while the dialog is open the player state pauses the combat AI - the bot visibly "shops".
/// </summary>
internal static class BotShoppingHandler
{
/// <summary>Start a shopping trip when fewer free backpack slots than this remain.</summary>
private const int FreeSlotPressure = 20;
/// <summary>Start a shopping trip when a potion kind has fewer charges than this.</summary>
private const int PotionLowThreshold = 40;
/// <summary>
/// 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.
/// </summary>
private const int PotionTargetCharges = 60;
/// <summary>Maximum purchases per potion kind per trip.</summary>
private const int MaxPurchasesPerKind = 20;
/// <summary>Zen the bot keeps in reserve - it stops buying rather than spend its last coin.</summary>
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,
];
/// <summary>
/// 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).
/// </summary>
/// <param name="player">The bot player.</param>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="map">The game map.</param>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="map">The game map.</param>
/// <param name="merchantPosition">The position of the merchant.</param>
/// <returns>True, if a merchant was found and the trade ran; false if no merchant is there.</returns>
public static async ValueTask<bool> 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;
}
/// <summary>
/// Gets the potion kinds (item numbers in group 14) whose stack is running low.
/// </summary>
private static IEnumerable<byte> 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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,177 @@
// <copyright file="BotSkillProgressionPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>
/// 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 <see cref="BotProgression.GetStatWeights"/>), 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 (<see cref="Skill.QualifiedCharacters"/>), using the same requirements the
/// game enforces for human players, so a grown bot matches a freshly generated one of the same level.
/// </summary>
[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();
/// <summary>
/// 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.
/// </summary>
/// <param name="player">The bot which entered the world.</param>
public static void CatchUpPendingProgress(Player player)
{
if (player.SelectedCharacter?.LevelUpPoints > 0)
{
CatchUp.CharacterLeveledUp(player);
}
}
/// <inheritdoc />
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);
}
}
/// <summary>
/// Changes the bot into its second-generation class (Dark Knight -> Blade Knight etc.) once it
/// reaches <see cref="BotProgression.ClassEvolutionLevel"/> - the exact assignment the class-change
/// quest performs for a human player (see <c>QuestCompletionAction</c>); 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.
/// </summary>
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);
}
}
}

View File

@@ -0,0 +1,263 @@
// <copyright file="BotWingHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Offline;
/// <summary>
/// Grants a bot its wings at the classic level milestones, like a player who saves up for them:
/// at level <see cref="FirstTierLevel"/> the first pair (+0, luck, +12 option), at
/// <see cref="SecondTierLevel"/> the second pair (+9, luck, +16 option) and at
/// <see cref="ThirdTierLevel"/> 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
/// <see cref="ItemDefinition.QualifiedCharacters"/>, 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.
/// </summary>
internal static class BotWingHandler
{
/// <summary>The level at which a bot gets its first tier wings (+0, luck, +12 option).</summary>
private const int FirstTierLevel = 180;
/// <summary>The level at which a bot gets its second tier wings (+9, luck, +16 option).</summary>
private const int SecondTierLevel = 280;
/// <summary>The level at which a bot gets its third tier wings (+15, luck, +16 option).</summary>
private const int ThirdTierLevel = 400;
/// <summary>The item level of the second tier grant; also disambiguates the tier of a cape (see <see cref="TierOf"/>).</summary>
private const byte SecondTierItemLevel = 9;
/// <summary>First tier wing numbers (group 12): Wings of Elf, Heaven, Satan and Curse.</summary>
private static readonly (byte Group, short Number)[] FirstTierIds = { (12, 0), (12, 1), (12, 2), (12, 41) };
/// <summary>Second tier wing numbers (group 12): Wings of Spirits, Soul, Dragon, Darkness and Despair.</summary>
private static readonly (byte Group, short Number)[] SecondTierIds = { (12, 3), (12, 4), (12, 5), (12, 6), (12, 42) };
/// <summary>Third tier wing numbers (group 12): Wing of Storm, Eternal, Illusion, Ruin, Dimension and the Capes of Emperor/Overrule.</summary>
private static readonly (byte Group, short Number)[] ThirdTierIds = { (12, 36), (12, 37), (12, 38), (12, 39), (12, 40), (12, 43), (12, 50) };
/// <summary>
/// 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
/// <see cref="PlanNextGrant"/> would keep out of the candidates anyway).
/// </summary>
private static readonly (byte Group, short Number)[] CapeIds = { (13, 30), (12, 49) };
/// <summary>
/// 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.
/// </summary>
/// <param name="player">The bot player.</param>
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);
}
}
/// <summary>
/// Determines the wings the bot has earned but does not wear yet, or <c>null</c> when it already
/// wears its best earned pair (or none is due). Pure decision logic - exposed for unit tests.
/// </summary>
/// <param name="player">The bot player.</param>
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(),
};
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Picks the wing's "additional" option (the +4-per-level one, <see cref="ItemOptionTypes.Option"/>)
/// 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.
/// </summary>
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>();
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<ItemOptionLink>();
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<ItemOptionLink>();
optionLink.ItemOption = option;
optionLink.Level = plan.OptionLevel;
item.ItemOptions.Add(optionLink);
}
return item;
}
}

View File

@@ -0,0 +1,13 @@
// <copyright file="PendingPartyInvite.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
/// <summary>
/// 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 <see cref="BotPartyHandler"/>).
/// </summary>
/// <param name="Requester">The player who invited the bot.</param>
/// <param name="AcceptAtUtc">When the bot answers the invitation.</param>
internal sealed record PendingPartyInvite(Player Requester, DateTime AcceptAtUtc);

View File

@@ -131,6 +131,17 @@ public class GameMap
return this._areaOfInterestManager.GetInRange(point, range).OfType<IAttackable>().ToList();
}
/// <summary>
/// Gets all non-player characters (e.g. merchants) within the specified range of a point.
/// </summary>
/// <param name="point">The coordinates.</param>
/// <param name="range">The range.</param>
/// <returns>The non-player characters in range of the specified coordinate.</returns>
public IList<NonPlayerCharacter> GetNpcsInRange(Point point, int range)
{
return this._areaOfInterestManager.GetInRange(point, range).OfType<NonPlayerCharacter>().ToList();
}
/// <summary>
/// Gets all dropped items and money within the specified range of a point.
/// </summary>

View File

@@ -137,6 +137,37 @@ public static class ItemExtensions
return item.Definition?.Group == ShieldItemGroup;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="itemDefinition">The definition of the item which would be equipped.</param>
/// <param name="inventory">The inventory to check the other hand in.</param>
/// <param name="toSlot">The hand slot the item would be equipped into.</param>
/// <returns><c>true</c> if the other hand blocks this item; otherwise, <c>false</c>.</returns>
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);
}
/// <summary>
/// Determines whether this item is a jewelry (pendant or ring) item.
/// </summary>

View File

@@ -150,6 +150,50 @@ public interface IMuHelperSettings
/// <summary>Gets a value indicating whether to automatically accept requests from guild.</summary>
bool AutoAcceptGuild { get; }
/// <summary>
/// Gets a value indicating whether to automatically accept party requests from anyone, not just
/// friends or guild mates. Defaults to <c>false</c>; used by server-side bots so they group up
/// with players who invite them (see <c>Bots.BotPartyHandler</c> for the applied safeguards).
/// </summary>
bool AutoAcceptAnyone => false;
/// <summary>Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used.</summary>
bool FallbackBasicAttack { get; }
/// <summary>
/// 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.
/// </summary>
bool AutoSelectBestSkill { get; }
/// <summary>
/// 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.
/// </summary>
bool AutoSelectBuffs { get; }
/// <summary>
/// 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.
/// </summary>
bool UseManaPotion { get; }
/// <summary>
/// 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.
/// </summary>
bool OnlyHuntSafeMonsters { get; }
/// <summary>
/// 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.
/// </summary>
bool PickUpgradeItems { get; }
}

View File

@@ -38,6 +38,13 @@ public static class PartyRequestHandler
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;
}

View File

@@ -25,6 +25,8 @@ public sealed class BuffHandler
private int _nextSlotIndex;
private bool _buffTimerTriggered;
private DateTime? _nextPeriodicBuffTime;
private IList<int>? _cachedAutoBuffIds;
private int _cachedAutoBuffSkillCount = -1;
/// <summary>
/// Initializes a new instance of the <see cref="BuffHandler"/> class.
@@ -38,7 +40,9 @@ public sealed class BuffHandler
}
/// <summary>
/// Gets the configured buff skill IDs from the settings.
/// Gets the configured buff skill IDs from the settings. With <see cref="IMuHelperSettings.AutoSelectBuffs"/>
/// 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.
/// </summary>
public IList<int> ConfiguredBuffIds
{
@@ -49,6 +53,34 @@ public sealed class BuffHandler
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 @@ public sealed class BuffHandler
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()

View File

@@ -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;
/// <summary>
/// See <see cref="IsSafeTarget"/>: 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.
/// </summary>
private const float SafeHitHealthShare = 0.15f;
/// <summary>
/// See <see cref="IsSafeTarget"/>: 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.
/// </summary>
private const float MinAttackAdvantage = 1.2f;
/// <summary>
/// See <see cref="IsSafeTarget"/>: 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.
/// </summary>
private const int MaxHitsToKill = 100;
/// <summary>
/// See <see cref="IsSafeTarget"/>: 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
/// <c>GameConfiguration.MinimumMonsterLevelForMasterExperience</c>, 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.
/// </summary>
private const int MasterHitBudgetFactor = 3;
private const int ComboFinisherDelayTicks = 3;
private const int InterSkillDelayTicks = 1;
private const int MinComboSkillCount = 3;
/// <summary>After this many consecutive failed approaches the target counts as unreachable.</summary>
private const int MaxApproachFailures = 3;
private const short DrainLifeBaseSkillId = 214;
private const short DrainLifeStrengthenerSkillId = 458;
private const short DrainLifeMasterySkillId = 462;
/// <summary>How long an unreachable target is ignored before it may be considered again.</summary>
private static readonly TimeSpan UnreachableTargetBlacklistDuration = TimeSpan.FromSeconds(10);
private static readonly TargetedSkillDefaultPlugin DefaultPlugin = new();
/// <summary>
/// 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 <see cref="MonsterDefinition"/> instances) reuses the entries instead of orphaning them.
/// </summary>
private static readonly ConcurrentDictionary<short, (int Level, float AverageDamage, float Defense, float Health, float AttackRate)> 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;
/// <summary>
/// Initializes a new instance of the <see cref="CombatHandler"/> class.
@@ -46,13 +105,11 @@ public sealed class CombatHandler
/// <param name="player">The offline player.</param>
/// <param name="config">The MU helper settings.</param>
/// <param name="movementHandler">The movement handler.</param>
/// <param name="originPosition">The original position to hunt around.</param>
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 sealed class CombatHandler
/// </summary>
public byte HuntingRange => CalculateHuntingRange(this._config);
/// <summary>
/// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds.
/// </summary>
private Point OriginPosition => this._player.HuntingOrigin;
/// <summary>
/// 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.
/// </summary>
private bool IsBot => this._player.Account?.IsBot == true;
/// <summary>
/// Calculates the hunting range in tiles from the specified configuration.
/// </summary>
@@ -85,6 +155,72 @@ public sealed class CombatHandler
return (byte)Math.Max(DefaultRange, config.HuntingRange);
}
/// <summary>
/// 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
/// <see cref="SafeHitHealthShare"/> 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 (<see cref="MinAttackAdvantage"/>),
/// 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
/// <see cref="BotResetHandler.GetEffectiveLevel"/>).
/// Shared by the combat AI and the bot navigator, so a bot never stops travelling for (or engages)
/// a monster it should not fight.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="monster">The monster definition.</param>
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;
}
/// <summary>
/// The number of net hits the bot may take to kill the monster (see <see cref="MaxHitsToKill"/>),
/// stretched by <see cref="MasterHitBudgetFactor"/> for a mastered bot fighting a monster which
/// actually pays it master experience (see <see cref="MasterHitBudgetFactor"/>).
/// </summary>
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;
}
/// <summary>
/// Decrements the skill cooldown counter by one tick.
/// </summary>
@@ -101,6 +237,7 @@ public sealed class CombatHandler
/// </summary>
public async ValueTask PerformAttackAsync()
{
var previousTarget = this._currentTarget;
this.RefreshTarget();
if (this._currentTarget is null)
@@ -108,13 +245,47 @@ public sealed class CombatHandler
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 sealed class CombatHandler
}
}
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);
}
/// <summary>
/// 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
/// <see cref="AttackableExtensions"/>), 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).
/// </summary>
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);
}
/// <summary>
/// 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".
/// </summary>
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 @@ public sealed class CombatHandler
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 @@ public sealed class CombatHandler
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 @@ public sealed class CombatHandler
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 @@ public sealed class CombatHandler
return [];
}
return map.GetAttackablesInRange(this._originPosition, this.HuntingRange)
return map.GetAttackablesInRange(this.OriginPosition, this.HuntingRange)
.OfType<Monster>()
.Where(this.IsMonsterAttackable);
}
/// <summary>
/// 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.
/// </summary>
private IEnumerable<IAttackable> 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 @@ public sealed class CombatHandler
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);
}
/// <summary>
/// With <see cref="IMuHelperSettings.OnlyHuntSafeMonsters"/> (server-side bots), the combat AI only
/// engages monsters which pass the same <see cref="IsSafeTarget"/> 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.
/// </summary>
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 @@ public sealed class CombatHandler
{
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<Player> playerTargets;
if (this._player.CurrentMiniGame is { AllowPlayerKilling: true })
{
playerTargets = this._player.CurrentMap?
.GetAttackablesInRange(target.Position, skill.Range)
.OfType<Player>()
.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 @@ public sealed class CombatHandler
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 @@ public sealed class CombatHandler
}
}
// 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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Evaluates whether the skill in the given slot should fire this tick.
/// </summary>
@@ -514,6 +944,15 @@ public sealed class CombatHandler
}
}
// 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))
{

View File

@@ -18,6 +18,9 @@ using MUnique.OpenMU.Interfaces;
/// </summary>
public sealed class HealingHandler
{
/// <summary>Drink a mana potion once mana falls below this share, so casters can keep casting.</summary>
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,7 +85,25 @@ public sealed class HealingHandler
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()
@@ -126,14 +154,16 @@ public sealed class HealingHandler
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

View File

@@ -120,13 +120,22 @@ public sealed class ItemPickupHandler
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;
}

View File

@@ -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
/// </summary>
/// <param name="player">The offline player.</param>
/// <param name="config">The MU Helper configuration.</param>
/// <param name="originPosition">The original spawn position.</param>
public MovementHandler(OfflinePlayer player, IMuHelperSettings? config, Point originPosition)
public MovementHandler(OfflinePlayer player, IMuHelperSettings? config)
{
this._player = player;
this._config = config;
this._originPosition = originPosition;
}
/// <summary>
/// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds.
/// </summary>
private Point OriginPosition => this._player.HuntingOrigin;
/// <summary>
/// Gets the hunting range in tiles.
/// </summary>
@@ -51,7 +53,7 @@ public sealed class MovementHandler
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 sealed class MovementHandler
/// </summary>
/// <param name="target">The target to move closer to.</param>
/// <param name="range">The range to stop within.</param>
public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range)
/// <returns>True, if a walk towards the target was started; false, if no path exists or walking is not possible.</returns>
public async ValueTask<bool> 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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
@@ -120,7 +152,7 @@ public sealed class MovementHandler
private bool ShouldRegroup(out double distance)
{
distance = this._player.GetDistanceTo(this._originPosition);
distance = this._player.GetDistanceTo(this.OriginPosition);
if (distance <= RegroupDistanceThreshold)
{
return false;

View File

@@ -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;
/// <summary>
/// An offline player that continues leveling after the real client disconnects.
/// </summary>
public sealed class OfflinePlayer : Player
public class OfflinePlayer : Player
{
/// <summary>
/// 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.
/// </summary>
private const int RepeatedKillThreshold = 2;
/// <summary>
/// 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 <see cref="Bots.BotPvpRules"/>.
/// </summary>
private static readonly TimeSpan AggressionMemory = TimeSpan.FromMinutes(5);
/// <summary>
/// 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.
/// </summary>
private static readonly TimeSpan RevengeDuration = TimeSpan.FromMinutes(3);
/// <summary>
/// How long the bot keeps away from hunting grounds near its death site after the same player
/// killed it repeatedly (see <see cref="RepeatedKillThreshold"/>).
/// </summary>
private static readonly TimeSpan DeathSiteAvoidanceDuration = TimeSpan.FromMinutes(10);
/// <summary>
/// How long a death counts toward <see cref="RepeatedKillThreshold"/>. A kill by a player the bot
/// has not seen for this long counts as a fresh grudge again, not as a repeated one.
/// </summary>
private static readonly TimeSpan DeathCountMemory = TimeSpan.FromMinutes(30);
/// <summary>
/// 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.
/// </summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DeathRecord> _deathsByKiller = new();
private OfflinePlayerMuHelper? _intelligence;
private Task? _intelligenceDisposeTask;
/// <summary>
/// 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 <see cref="DateTime"/> read.
/// </summary>
private volatile Aggression? _aggression;
/// <summary>
/// The pending (not yet armed, <see cref="RevengeState.ExpiresAtUtc"/> 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.
/// </summary>
private volatile RevengeState? _revenge;
/// <summary>See <see cref="TryGetDeathSiteToAvoid"/>; immutable and written atomically, like <see cref="_revenge"/>.</summary>
private volatile DeathSite? _deathSiteToAvoid;
/// <summary>
/// Initializes a new instance of the <see cref="OfflinePlayer"/> class.
/// </summary>
@@ -36,6 +92,71 @@ public sealed class OfflinePlayer : Player
/// </summary>
public DateTime StartTimestamp { get; internal set; }
/// <summary>
/// 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.
/// </summary>
public Point HuntingOrigin { get; set; }
/// <summary>
/// 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.
/// </summary>
public virtual bool RespawnAndContinue => false;
/// <summary>
/// Gets actions queued from outside the AI tick (e.g. skill learning on level-up), which the
/// <see cref="OfflinePlayerMuHelper"/> 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.
/// </summary>
internal System.Collections.Concurrent.ConcurrentQueue<Func<ValueTask>> PendingBotActions { get; } = new();
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// Gets or sets the pending party invitation from a player, scheduled by
/// <see cref="Bots.BotPartyHandler"/> and executed with a human-like delay in the bot's tick.
/// </summary>
internal Bots.PendingPartyInvite? PendingPartyInvite { get; set; }
/// <summary>
/// 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 <see cref="Bots.BotPartyHandler"/>).
/// </summary>
internal DateTime? PartyBoredomAtUtc { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the bot is currently on a shopping trip (walking to
/// or trading with a merchant), maintained by <see cref="Bots.BotNavigator"/>. While on an errand
/// the bot declines party invitations, like a busy player would.
/// </summary>
internal bool IsOnShoppingTrip { get; set; }
/// <summary>
/// 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.
/// </summary>
internal bool HasRevengeIntent => this._revenge is not null;
/// <summary>
/// Initializes the offline player by loading the account fresh from the database.
/// </summary>
@@ -70,6 +191,8 @@ public sealed class OfflinePlayer : Player
await this.ClientReadyAfterMapChangeAsync().ConfigureAwait(false);
this.HuntingOrigin = this.Position;
this.StartIntelligence();
this.Logger.LogDebug(
@@ -90,11 +213,188 @@ public sealed class OfflinePlayer : Player
/// <summary>
/// Stops the offline player and removes it from the world.
/// </summary>
public async ValueTask StopAsync()
public virtual async ValueTask StopAsync()
{
await this.DisconnectAsync().ConfigureAwait(false);
}
/// <summary>
/// Registers a player who attacked this bot, so the combat AI can defend itself.
/// </summary>
/// <param name="aggressor">The player who attacked this bot.</param>
internal void RegisterAggressor(Player aggressor)
{
this._aggression = new Aggression(aggressor, DateTime.UtcNow);
}
/// <summary>
/// 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
/// <see cref="Bots.BotNavigator"/>) with re-armed aggressor memory, so it attacks the killer on
/// sight. A repeated kill by the same player (see <see cref="RepeatedKillThreshold"/>) cancels
/// revenge instead and makes the bot avoid hunting grounds near the death site for a while.
/// </summary>
/// <param name="killer">The player who killed this bot.</param>
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);
}
/// <summary>
/// Arms a pending revenge once the bot respawned, called by the <see cref="OfflinePlayerMuHelper"/>
/// 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 <see cref="Bots.BotPvpRules"/>),
/// and the revenge gets its time-to-live.
/// </summary>
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);
}
/// <summary>
/// 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).
/// </summary>
/// <param name="currentMap">The map the bot is currently on.</param>
/// <param name="deathSite">The place of the bot's death to march back to.</param>
/// <returns><c>true</c> if a revenge is active and <paramref name="deathSite"/> was set.</returns>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="reason">Why the revenge ended, for the log.</param>
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);
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="currentMap">The map the bot is currently on.</param>
/// <param name="deathSite">The place of the repeated deaths.</param>
/// <returns><c>true</c> if an avoidance is active on the given map and <paramref name="deathSite"/> was set.</returns>
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;
}
/// <summary>
/// Executes and removes all queued <see cref="PendingBotActions"/>.
/// </summary>
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);
}
}
}
/// <summary>
/// Called when an AI tick of this player finished without an exception. Does nothing here - a bot
/// uses it to forget earlier failures (see <see cref="Bots.BotPlayer"/>).
/// </summary>
internal virtual void OnAiTickSucceeded()
{
// Nothing to do for a plain offline player.
}
/// <summary>
/// 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 <see cref="Bots.BotPlayer"/>).
/// </summary>
internal virtual void OnAiTickFailed()
{
// Nothing to do for a plain offline player.
}
/// <inheritdoc />
protected override async ValueTask InternalDisconnectAsync()
{
@@ -132,6 +432,15 @@ public sealed class OfflinePlayer : Player
protected override ICustomPlugInContainer<IViewPlugIn> CreateViewPlugInContainer()
=> new OfflineViewPlugInContainer(this);
/// <summary>
/// Starts the intelligence which drives this offline player. Overridden by bots to also run navigation.
/// </summary>
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 @@ public sealed class OfflinePlayer : Player
await this.SetSelectedCharacterAsync(character).ConfigureAwait(false);
}
private void StartIntelligence()
{
this._intelligence = new OfflinePlayerMuHelper(this);
this._intelligence.Start();
}
}
/// <summary>
/// How often (and how recently) a specific player killed this bot.
/// </summary>
private sealed record DeathRecord(int Count, DateTime LastDeathUtc);
/// <summary>
/// A revenge for a death by a player's hand: pending while <see cref="ExpiresAtUtc"/> is null
/// (the bot has not respawned yet), armed and running once it is set.
/// </summary>
private sealed record RevengeState(Player Killer, Point DeathPosition, GameMapDefinition DeathMap, DateTime? ExpiresAtUtc);
/// <summary>
/// A death site the bot avoids when picking hunting grounds, after repeated deaths there.
/// </summary>
private sealed record DeathSite(Point Position, GameMapDefinition Map, DateTime AvoidUntilUtc);
/// <summary>
/// The most recent aggression against this bot: who attacked and when.
/// </summary>
private sealed record Aggression(Player Aggressor, DateTime AtUtc);
}

View File

@@ -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 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
{
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 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
try
{
await this.TickAsync(cancellationToken).ConfigureAwait(false);
this._player.OnAiTickSucceeded();
}
catch (OperationCanceledException)
{
@@ -144,11 +151,16 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
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 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
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);

View File

@@ -34,6 +34,14 @@ internal sealed class ZenConsumptionHandler
/// <returns><c>true</c> if the player can continue; <c>false</c> if insufficient Zen.</returns>
public async ValueTask<bool> 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;

View File

@@ -263,18 +263,7 @@ public class MoveItemAction
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.

View File

@@ -99,6 +99,10 @@ public class EnterMiniGameAction
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 class EnterMiniGameAction
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
{

View File

@@ -28,7 +28,12 @@ public class PartyRequestAction
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))
{

View File

@@ -29,8 +29,13 @@ public abstract class UnlockCharacterAtLevelBase : ICharacterLevelUpPlugIn
/// <inheritdoc />
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);

View File

@@ -155,6 +155,26 @@ public sealed class MuHelperSettings : IMuHelperSettings
/// <summary>Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used.</summary>
public bool FallbackBasicAttack { get; init; }
/// <inheritdoc />
/// <remarks>Human MU Helper sessions drive their skills through the explicit client configuration, so this is always <c>false</c>.</remarks>
public bool AutoSelectBestSkill => false;
/// <inheritdoc />
/// <remarks>Human MU Helper sessions configure their buff slots explicitly, so this is always <c>false</c>.</remarks>
public bool AutoSelectBuffs => false;
/// <inheritdoc />
/// <remarks>The client-side MU Helper has no mana potion setting; this only exists for server-side bots.</remarks>
public bool UseManaPotion => false;
/// <inheritdoc />
/// <remarks>A human picked their hunting spot deliberately, so their offline session fights whatever is there.</remarks>
public bool OnlyHuntSafeMonsters => false;
/// <inheritdoc />
/// <remarks>Equipment progression is a server-side bot behavior only.</remarks>
public bool PickUpgradeItems => false;
/// <inheritdoc />
public override string ToString()
{

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
// <copyright file="20260627120000_AddAccountIsBot.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class AddAccountIsBot : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsBot",
schema: "data",
table: "Account",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsBot",
schema: "data",
table: "Account");
}
}
}

View File

@@ -35,6 +35,9 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsBot")
.HasColumnType("boolean");
b.Property<bool>("IsTemplate")
.HasColumnType("boolean");

View File

@@ -0,0 +1,177 @@
// <copyright file="BotEquipmentHandlerTest.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests;
using Moq;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Bots;
/// <summary>
/// Tests which gear a bot considers an upgrade (<see cref="BotEquipmentHandler.IsUpgradeFor"/>, also
/// the pickup filter of the offline <see cref="MUnique.OpenMU.GameLogic.Offline.ItemPickupHandler"/>)
/// and the hand rule it shares with the engine (<see cref="ItemExtensions.ConflictsWithEquippedHands"/>).
/// </summary>
[TestFixture]
public class BotEquipmentHandlerTest
{
private const byte StaffGroup = 5;
private const byte ShieldGroup = 6;
private const byte ArmorGroup = 8;
/// <summary>
/// A better weapon of the bot's own fighting style is an upgrade worth picking up. The test player's
/// class is energy-based, so its style is the staff (see <see cref="BotProgression.IsPreferredWeaponGroup"/>).
/// </summary>
[Test]
public async ValueTask BetterWeaponIsAnUpgradeAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 10), InventoryConstants.LeftHandSlot).ConfigureAwait(false);
var better = CreateItem(CreateDefinition(player, StaffGroup, 2, dropLevel: 40));
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, better), Is.True);
}
/// <summary>
/// A two-handed weapon needs the other hand: while a shield is worn, it is only worth it if it beats
/// the weapon AND the shield it displaces. Without counting the shield, the bot took its gear off,
/// had the equip refused by the engine (a two-hander needs the hand free), put the old weapon back on
/// and started over - hundreds of swaps an hour, unarmed half of the time.
/// </summary>
[Test]
public async ValueTask TwoHandedWeaponIsNoUpgradeWhenItLosesToWeaponAndShieldAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 50), InventoryConstants.LeftHandSlot).ConfigureAwait(false);
await WearAsync(player, CreateDefinition(player, ShieldGroup, 1, dropLevel: 40, slot: InventoryConstants.RightHandSlot), InventoryConstants.RightHandSlot).ConfigureAwait(false);
var twoHanded = CreateItem(CreateDefinition(player, StaffGroup, 3, dropLevel: 60, width: 2));
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, twoHanded), Is.False);
}
/// <summary>
/// A two-handed weapon which beats the worn weapon and shield together is worth the swap - the bot
/// frees the hand for it, like a player would.
/// </summary>
[Test]
public async ValueTask TwoHandedWeaponIsAnUpgradeWhenItBeatsWeaponAndShieldAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 50), InventoryConstants.LeftHandSlot).ConfigureAwait(false);
await WearAsync(player, CreateDefinition(player, ShieldGroup, 1, dropLevel: 40, slot: InventoryConstants.RightHandSlot), InventoryConstants.RightHandSlot).ConfigureAwait(false);
var twoHanded = CreateItem(CreateDefinition(player, StaffGroup, 3, dropLevel: 120, width: 2));
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, twoHanded), Is.True);
}
/// <summary>
/// Without a shield in the way, the same two-handed weapon is a welcome upgrade.
/// </summary>
[Test]
public async ValueTask TwoHandedWeaponIsAnUpgradeWithFreeOffHandAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 10), InventoryConstants.LeftHandSlot).ConfigureAwait(false);
var twoHanded = CreateItem(CreateDefinition(player, StaffGroup, 3, dropLevel: 60, width: 2));
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, twoHanded), Is.True);
}
/// <summary>
/// A weapon never goes into the free off-hand: a bot dual-wielding the junk weapons it happens to be
/// qualified for is neither useful nor a sight a real character offers.
/// </summary>
[Test]
public async ValueTask JunkWeaponIsNoUpgradeForTheFreeOffHandAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 50), InventoryConstants.LeftHandSlot).ConfigureAwait(false);
var junk = CreateItem(CreateDefinition(player, StaffGroup, 2, dropLevel: 5));
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, junk), Is.False);
}
/// <summary>
/// Gear the bot's class cannot wear is no upgrade, however good it is.
/// </summary>
[Test]
public async ValueTask UnqualifiedGearIsNoUpgradeAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var definition = CreateDefinition(player, ArmorGroup, 1, dropLevel: 80, slot: InventoryConstants.ArmorSlot);
definition.QualifiedCharacters.Clear();
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, CreateItem(definition)), Is.False);
}
/// <summary>
/// An empty armor slot takes any qualified piece - that is what makes a naked bot dress itself.
/// </summary>
[Test]
public async ValueTask ArmorForAnEmptySlotIsAnUpgradeAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var armor = CreateItem(CreateDefinition(player, ArmorGroup, 1, dropLevel: 20, slot: InventoryConstants.ArmorSlot));
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, armor), Is.True);
}
private static ItemDefinition CreateDefinition(Player player, byte group, short number, byte dropLevel, byte width = 1, int? slot = null)
{
var definitionMock = new Mock<ItemDefinition>();
definitionMock.SetupAllProperties();
definitionMock.Setup(d => d.QualifiedCharacters).Returns(new List<CharacterClass>());
definitionMock.Setup(d => d.PossibleItemOptions).Returns(new List<ItemOptionDefinition>());
definitionMock.Setup(d => d.BasePowerUpAttributes).Returns(new List<ItemBasePowerUpDefinition>());
definitionMock.Setup(d => d.Requirements).Returns(new List<AttributeRequirement>());
var slotTypeMock = new Mock<ItemSlotType>();
var targetSlot = slot ?? InventoryConstants.LeftHandSlot;
var slots = targetSlot == InventoryConstants.LeftHandSlot && group <= ShieldGroup && width < 2
? new List<int> { InventoryConstants.LeftHandSlot, InventoryConstants.RightHandSlot }
: new List<int> { targetSlot };
slotTypeMock.Setup(s => s.ItemSlots).Returns(slots);
definitionMock.Setup(d => d.ItemSlot).Returns(slotTypeMock.Object);
var definition = definitionMock.Object;
definition.Group = group;
definition.Number = number;
definition.Width = width;
definition.Height = 2;
definition.Durability = 100;
definition.DropLevel = dropLevel;
definition.QualifiedCharacters.Add(player.SelectedCharacter!.CharacterClass!);
player.GameContext.Configuration.Items.Add(definition);
return definition;
}
private static Item CreateItem(ItemDefinition definition)
{
var itemMock = new Mock<Item>();
itemMock.SetupAllProperties();
itemMock.Setup(i => i.ItemOptions).Returns(new List<ItemOptionLink>());
itemMock.Setup(i => i.ItemSetGroups).Returns(new List<ItemOfItemSet>());
var item = itemMock.Object;
item.Definition = definition;
item.Durability = definition.Durability;
return item;
}
private static async ValueTask<Item> WearAsync(Player player, ItemDefinition definition, byte slot)
{
var item = CreateItem(definition);
await player.Inventory!.AddItemAsync(slot, item).ConfigureAwait(false);
return item;
}
}

View File

@@ -0,0 +1,197 @@
// <copyright file="BotJewelHandlerTest.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests;
using Moq;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Bots;
/// <summary>
/// Tests the jewel usage policy of <see cref="BotJewelHandler"/> - which jewel a bot picks for which
/// equipped item; the actual consumption goes through the regular consume handlers and is covered by
/// <see cref="ItemConsumptionTest"/>.
/// </summary>
[TestFixture]
public class BotJewelHandlerTest
{
private const byte FirstBackpackSlot = 12;
/// <summary>
/// A Bless in stock and an equipped piece below +6: the weakest piece is chosen for the Bless.
/// </summary>
[Test]
public async ValueTask PrefersBlessOnWeakestUpgradeableItemAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
// The stronger piece is the one the bot must NOT pick, so it only has to be there.
await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 4).ConfigureAwait(false);
var weakPiece = await AddEquippedItemAsync(player, InventoryConstants.RightHandSlot, 2).ConfigureAwait(false);
var bless = await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfBless).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false);
var plan = BotJewelHandler.PlanNextUse(player, false);
Assert.That(plan, Is.Not.Null);
Assert.That(plan!.Value.Jewel, Is.SameAs(bless));
Assert.That(plan.Value.Target, Is.SameAs(weakPiece));
Assert.That(plan.Value.IsLife, Is.False);
}
/// <summary>
/// All gear at +6 or above: a Soul is only risked with a spare in stock.
/// </summary>
/// <param name="soulCount">The number of souls in the backpack.</param>
/// <param name="expectsUse">Whether a jewel use is expected.</param>
[TestCase(1, false)]
[TestCase(2, true)]
public async ValueTask RisksSoulOnlyWithSpareStockAsync(int soulCount, bool expectsUse)
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 6).ConfigureAwait(false);
for (var i = 0; i < soulCount; i++)
{
await AddJewelAsync(player, (byte)(FirstBackpackSlot + i), ItemConstants.JewelOfSoul).ConfigureAwait(false);
}
var plan = BotJewelHandler.PlanNextUse(player, false);
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
}
/// <summary>
/// The Soul prefers a lucky target (its success bonus) over a lower-level one without luck.
/// </summary>
[Test]
public async ValueTask SoulPrefersLuckyItemAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 6).ConfigureAwait(false);
var luckyPiece = await AddEquippedItemAsync(player, InventoryConstants.RightHandSlot, 7, withLuck: true).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfSoul).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false);
var plan = BotJewelHandler.PlanNextUse(player, false);
Assert.That(plan, Is.Not.Null);
Assert.That(plan!.Value.Target, Is.SameAs(luckyPiece));
}
/// <summary>
/// Without luck the Soul risk stops at +6 (a failure from +7 on resets the item to +0), so only
/// lucky items may be pushed further - up to the jewel ceiling of +9.
/// </summary>
/// <param name="itemLevel">The level of the equipped item.</param>
/// <param name="withLuck">Whether the equipped item has luck.</param>
/// <param name="expectsUse">Whether a jewel use is expected.</param>
[TestCase(7, false, false)]
[TestCase(7, true, true)]
[TestCase(8, true, true)]
[TestCase(9, true, false)]
public async ValueTask RisksSoulAbovePlusSixOnlyWithLuckAsync(byte itemLevel, bool withLuck, bool expectsUse)
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, itemLevel, withLuck).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfSoul).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false);
var plan = BotJewelHandler.PlanNextUse(player, false);
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
}
/// <summary>
/// Life is the last resort and is planned at most once per trip.
/// </summary>
/// <param name="lifeAlreadyUsed">Whether a life was already used within the trip.</param>
/// <param name="expectsUse">Whether a jewel use is expected.</param>
[TestCase(false, true)]
[TestCase(true, false)]
public async ValueTask UsesLifeAtMostOncePerTripAsync(bool lifeAlreadyUsed, bool expectsUse)
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var target = await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 9).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfLife).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfLife).ConfigureAwait(false);
var plan = BotJewelHandler.PlanNextUse(player, lifeAlreadyUsed);
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
if (expectsUse)
{
Assert.That(plan!.Value.Target, Is.SameAs(target));
Assert.That(plan.Value.IsLife, Is.True);
}
}
/// <summary>
/// Without any applicable jewel or target, nothing is planned.
/// </summary>
[Test]
public async ValueTask PlansNothingWithoutJewelsAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 2).ConfigureAwait(false);
var plan = BotJewelHandler.PlanNextUse(player, false);
Assert.That(plan, Is.Null);
}
private static async ValueTask<Item> AddEquippedItemAsync(Player player, byte slot, byte level, bool withLuck = false)
{
var item = new Mock<Item>();
item.SetupAllProperties();
var itemOptions = new List<ItemOptionLink>();
item.Setup(i => i.ItemOptions).Returns(itemOptions);
item.Setup(i => i.ItemSetGroups).Returns(new List<ItemOfItemSet>());
var definition = new Mock<ItemDefinition>();
definition.SetupAllProperties();
var itemSlot = new Mock<ItemSlotType>();
itemSlot.Setup(s => s.ItemSlots).Returns(new List<int> { slot });
definition.Setup(d => d.ItemSlot).Returns(itemSlot.Object);
item.Object.Definition = definition.Object;
item.Object.Definition.Width = 1;
item.Object.Definition.Height = 1;
item.Object.Definition.MaximumItemLevel = 15;
item.Object.Definition.Durability = 1;
item.Object.Durability = 1;
item.Object.Level = level;
if (withLuck)
{
var optionLink = new Mock<ItemOptionLink>();
optionLink.SetupAllProperties();
var option = new Mock<IncreasableItemOption>();
option.SetupAllProperties();
option.Object.OptionType = ItemOptionTypes.Luck;
optionLink.Object.ItemOption = option.Object;
itemOptions.Add(optionLink.Object);
}
await player.Inventory!.AddItemAsync(slot, item.Object).ConfigureAwait(false);
return item.Object;
}
private static async ValueTask<Item> AddJewelAsync(Player player, int slot, ItemIdentifier identifier)
{
var jewel = new Item
{
Definition = new ItemDefinition
{
Number = identifier.Number ?? 0,
Group = identifier.Group,
Width = 1,
Height = 1,
},
Durability = 1,
};
await player.Inventory!.AddItemAsync((byte)slot, jewel).ConfigureAwait(false);
return jewel;
}
}

View File

@@ -0,0 +1,175 @@
// <copyright file="BotMiniGameHandlerTest.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.Offline;
/// <summary>
/// Tests the entry decisions of <see cref="BotMiniGameHandler"/> - which party bots may follow
/// their human leader into a mini game event, and who is taken along at all. The actual entry
/// goes through the regular <see cref="MUnique.OpenMU.GameLogic.MiniGames.MiniGameContext"/>.
/// </summary>
[TestFixture]
public class BotMiniGameHandlerTest
{
/// <summary>
/// The event's character level bracket is enforced in both directions.
/// </summary>
/// <param name="level">The bot's character level.</param>
/// <param name="expected">Whether the bot qualifies.</param>
[TestCase(200, false)]
[TestCase(281, true)]
[TestCase(330, true)]
[TestCase(331, false)]
public async ValueTask EnforcesLevelBracketAsync(int level, bool expected)
{
var gameContext = GameContextTestHelper.CreateGameContext();
var bot = await CreateBotAsync(gameContext).ConfigureAwait(false);
bot.Attributes![Stats.Level] = level;
var definition = new MiniGameDefinition { MinimumCharacterLevel = 281, MaximumCharacterLevel = 330 };
var eligible = BotMiniGameHandler.IsEligible(bot, definition, out var reason);
Assert.That(eligible, Is.EqualTo(expected));
Assert.That(reason, expected ? Is.Empty : Is.Not.Empty);
}
/// <summary>
/// The special characters (Magic Gladiator, Dark Lord, Rage Fighter, Summoner) enter in their own
/// level bracket, exactly like they do for a player: a qualified Magic Gladiator must not be judged
/// - and kicked out of its leader's party - by the bracket of the regular classes.
/// </summary>
/// <param name="level">The bot's character level.</param>
/// <param name="expected">Whether the bot qualifies.</param>
[TestCase(200, false)]
[TestCase(221, true)]
[TestCase(280, true)]
[TestCase(281, false)]
public async ValueTask EnforcesSpecialCharacterLevelBracketAsync(int level, bool expected)
{
var gameContext = GameContextTestHelper.CreateGameContext();
var bot = await CreateBotAsync(gameContext).ConfigureAwait(false);
bot.Attributes![Stats.Level] = level;
// That is what makes a character "special" for the entry rules (see CharacterExtensions).
bot.SelectedCharacter!.CharacterClass!.LevelWarpRequirementReductionPercent = 50;
var definition = new MiniGameDefinition
{
MinimumCharacterLevel = 281,
MaximumCharacterLevel = 330,
MinimumSpecialCharacterLevel = 221,
MaximumSpecialCharacterLevel = 280,
};
var eligible = BotMiniGameHandler.IsEligible(bot, definition, out _);
Assert.That(eligible, Is.EqualTo(expected));
}
/// <summary>
/// An event for master classes only is not entered before the bot's master evolution.
/// </summary>
/// <param name="isMasterClass">Whether the bot evolved into its master class.</param>
/// <param name="expected">Whether the bot qualifies.</param>
[TestCase(false, false)]
[TestCase(true, true)]
public async ValueTask EnforcesMasterClassRequirementAsync(bool isMasterClass, bool expected)
{
var gameContext = GameContextTestHelper.CreateGameContext();
var bot = await CreateBotAsync(gameContext).ConfigureAwait(false);
bot.Attributes![Stats.Level] = 400;
bot.SelectedCharacter!.CharacterClass!.IsMasterClass = isMasterClass;
var definition = new MiniGameDefinition { MinimumCharacterLevel = 0, MaximumCharacterLevel = 400, RequiresMasterClass = true };
var eligible = BotMiniGameHandler.IsEligible(bot, definition, out _);
Assert.That(eligible, Is.EqualTo(expected));
}
/// <summary>
/// A player killer bot (should never happen, but the rule is mirrored from the player entry)
/// cannot enter events which disallow player killers.
/// </summary>
/// <param name="killersAllowed">Whether the event allows player killers.</param>
/// <param name="expected">Whether the bot qualifies.</param>
[TestCase(false, false)]
[TestCase(true, true)]
public async ValueTask EnforcesPlayerKillerRestrictionAsync(bool killersAllowed, bool expected)
{
var gameContext = GameContextTestHelper.CreateGameContext();
var bot = await CreateBotAsync(gameContext).ConfigureAwait(false);
bot.Attributes![Stats.Level] = 100;
bot.SelectedCharacter!.State = HeroState.PlayerKiller1stStage;
var definition = new MiniGameDefinition { MinimumCharacterLevel = 0, MaximumCharacterLevel = 400, ArePlayerKillersAllowedToEnter = killersAllowed };
var eligible = BotMiniGameHandler.IsEligible(bot, definition, out _);
Assert.That(eligible, Is.EqualTo(expected));
}
/// <summary>
/// Only the bots of the party whose MASTER enters are taken along - and only the bots, not the
/// human members.
/// </summary>
[Test]
public async ValueTask SnapshotTakesOnlyBotsOfTheEnteringMasterAsync()
{
var gameContext = GameContextTestHelper.CreateGameContext();
var leader = await CreateHumanAsync(gameContext, "Leader").ConfigureAwait(false);
var member = await CreateHumanAsync(gameContext, "Member").ConfigureAwait(false);
var bot1 = await CreateBotAsync(gameContext, "BotOne").ConfigureAwait(false);
var bot2 = await CreateBotAsync(gameContext, "BotTwo").ConfigureAwait(false);
var party = gameContext.PartyManager.CreateParty();
await party.AddAsync(leader).ConfigureAwait(false);
await party.AddAsync(member).ConfigureAwait(false);
await party.AddAsync(bot1).ConfigureAwait(false);
await party.AddAsync(bot2).ConfigureAwait(false);
var fromLeader = BotMiniGameHandler.SnapshotPartyBots(leader);
var fromMember = BotMiniGameHandler.SnapshotPartyBots(member);
Assert.That(fromLeader, Is.EquivalentTo(new[] { bot1, bot2 }));
Assert.That(fromMember, Is.Empty);
}
/// <summary>
/// A player without a party (or a bot, however it would get here) takes nobody along.
/// </summary>
[Test]
public async ValueTask SnapshotIsEmptyWithoutPartyAsync()
{
var gameContext = GameContextTestHelper.CreateGameContext();
var solo = await CreateHumanAsync(gameContext, "Solo").ConfigureAwait(false);
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
Assert.That(BotMiniGameHandler.SnapshotPartyBots(solo), Is.Empty);
Assert.That(BotMiniGameHandler.SnapshotPartyBots(bot), Is.Empty);
}
private static async ValueTask<OfflinePlayer> CreateBotAsync(IGameContext gameContext, string name = "Bot")
{
var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false);
await bot.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
bot.SelectedCharacter!.Name = name;
bot.IsAlive = true;
bot.Account!.IsBot = true;
return bot;
}
private static async ValueTask<Player> CreateHumanAsync(IGameContext gameContext, string name)
{
var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
player.SelectedCharacter!.Name = name;
player.IsAlive = true;
return player;
}
}

View File

@@ -0,0 +1,49 @@
// <copyright file="BotSelfHealingTest.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests;
using MUnique.OpenMU.GameLogic.Bots;
/// <summary>
/// Tests how a bot reacts to its AI failing: the engine's attribute system is not thread-safe, and a
/// lost race can corrupt a character's attribute graph for good - from then on every tick throws, the
/// bot stops playing and floods the log. It asks for a restart, which rebuilds the graph and heals it.
/// </summary>
[TestFixture]
public class BotSelfHealingTest
{
/// <summary>
/// A tick failing now and then is simply skipped, like before - no restart.
/// </summary>
[Test]
public void SingleFailuresDoNotRestartTheBot()
{
var bot = new BotPlayer(GameContextTestHelper.CreateGameContext());
for (var i = 0; i < 100; i++)
{
bot.OnAiTickFailed();
bot.OnAiTickSucceeded();
}
Assert.That(bot.AwaitsFaultRestart, Is.False);
}
/// <summary>
/// A bot whose ticks keep failing is broken and asks the maintenance pass to restart it.
/// </summary>
[Test]
public void PersistentFailuresRestartTheBot()
{
var bot = new BotPlayer(GameContextTestHelper.CreateGameContext());
for (var i = 0; i < 20; i++)
{
bot.OnAiTickFailed();
}
Assert.That(bot.AwaitsFaultRestart, Is.True);
}
}

View File

@@ -0,0 +1,123 @@
// <copyright file="BotServerPartitionTest.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests;
using MUnique.OpenMU.GameLogic.Bots;
/// <summary>
/// Tests how the bot population is split over the game servers of a deployment
/// (<see cref="BotServerPartition"/>): bots count towards the player limit of their server, so a server
/// must never animate more of them than its reserved share allows - and the servers must agree on who
/// animates whom without asking each other.
/// </summary>
[TestFixture]
public class BotServerPartitionTest
{
/// <summary>
/// A single game server animates the accounts which fit into its share; the rest stays offline
/// instead of filling the server up, because the remaining capacity belongs to the players.
/// </summary>
[Test]
public void SingleServerTakesWhatFits()
{
var (partition, assigned) = BotServerPartition.Split([(0, 120)], 0, 220);
Assert.That(partition.FirstAccount, Is.EqualTo(1));
Assert.That(partition.AccountCount, Is.EqualTo(120));
Assert.That(partition.IsGenerator, Is.True);
Assert.That(assigned, Is.EqualTo(120));
}
/// <summary>
/// The scenario the split is made for: one server was crowded, a second one is added, and the
/// population spreads over BOTH of them - a player who picks the new server meets bots there, too.
/// </summary>
[Test]
public void PopulationSpreadsOverBothServers()
{
List<(byte ServerId, int Capacity)> capacities = [(0, 120), (1, 120)];
var (first, assigned) = BotServerPartition.Split(capacities, 0, 220);
var (second, _) = BotServerPartition.Split(capacities, 1, 220);
Assert.That(assigned, Is.EqualTo(220));
Assert.That(first.AccountCount, Is.EqualTo(110));
Assert.That(second.AccountCount, Is.EqualTo(110));
Assert.That(second.FirstAccount, Is.EqualTo(111));
}
/// <summary>
/// The invariant which protects the characters: every account is animated by exactly one server.
/// Two servers animating one account is the cross-context situation which corrupts it.
/// </summary>
/// <param name="requestedAccounts">The configured number of bot accounts.</param>
[TestCase(1)]
[TestCase(7)]
[TestCase(220)]
[TestCase(1000)]
public void EveryAccountIsAnimatedExactlyOnce(int requestedAccounts)
{
// Deliberately uneven capacities, so the rounding of the shares is exercised.
List<(byte ServerId, int Capacity)> capacities = [(0, 37), (1, 90), (2, 113)];
var partitions = capacities
.Select(server => BotServerPartition.Split(capacities, server.ServerId, requestedAccounts))
.ToList();
var assigned = partitions[0].AssignedAccounts;
Assert.That(partitions.Sum(p => p.Partition.AccountCount), Is.EqualTo(assigned));
for (var account = 1; account <= assigned; account++)
{
Assert.That(partitions.Count(p => p.Partition.Owns(account)), Is.EqualTo(1), $"account {account}");
}
}
/// <summary>
/// Exactly one server generates the population, so the accounts - and their unique character names -
/// are never created twice at the same time.
/// </summary>
[Test]
public void OnlyTheFirstServerGenerates()
{
List<(byte ServerId, int Capacity)> capacities = [(0, 50), (1, 50), (2, 50)];
Assert.That(BotServerPartition.Split(capacities, 0, 150).Partition.IsGenerator, Is.True);
Assert.That(BotServerPartition.Split(capacities, 1, 150).Partition.IsGenerator, Is.False);
Assert.That(BotServerPartition.Split(capacities, 2, 150).Partition.IsGenerator, Is.False);
}
/// <summary>
/// The servers may have different player limits; the shares follow their capacity.
/// </summary>
[Test]
public void SharesFollowTheServerCapacity()
{
List<(byte ServerId, int Capacity)> capacities = [(0, 30), (1, 90)];
var (small, _) = BotServerPartition.Split(capacities, 0, 120);
var (big, _) = BotServerPartition.Split(capacities, 1, 120);
Assert.That(small.AccountCount, Is.EqualTo(30));
Assert.That(big.AccountCount, Is.EqualTo(90));
Assert.That(big.FirstAccount, Is.EqualTo(31));
}
/// <summary>
/// A server without any bot capacity (its player limit is reserved for players entirely) animates
/// nothing, and the other servers still cover the whole population.
/// </summary>
[Test]
public void ServerWithoutCapacityAnimatesNothing()
{
List<(byte ServerId, int Capacity)> capacities = [(0, 100), (1, 0)];
var (empty, assigned) = BotServerPartition.Split(capacities, 1, 60);
Assert.That(empty.AccountCount, Is.EqualTo(0));
Assert.That(empty.IsGenerator, Is.False);
Assert.That(empty.Owns(1), Is.False);
Assert.That(assigned, Is.EqualTo(60));
Assert.That(BotServerPartition.Split(capacities, 0, 60).Partition.AccountCount, Is.EqualTo(60));
}
}

View File

@@ -0,0 +1,225 @@
// <copyright file="BotWingHandlerTest.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests;
using Moq;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.DataModel.Attributes;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Bots;
/// <summary>
/// Tests the wing milestone policy of <see cref="BotWingHandler"/> - which wings a bot has earned
/// at which level; the creation and equipping go through the regular persistence context and
/// <see cref="MUnique.OpenMU.GameLogic.PlayerActions.Items.MoveItemAction"/>.
/// </summary>
[TestFixture]
public class BotWingHandlerTest
{
private const short FirstTierWingNumber = 2;
private const short SecondTierWingNumber = 5;
private const short ThirdTierWingNumber = 36;
/// <summary>The Cape of Lord lives in group 13, unlike all other wings (group 12).</summary>
private const short CapeNumber = 30;
private const byte CapeGroup = 13;
/// <summary>
/// Below the first milestone no wings are due.
/// </summary>
[Test]
public async ValueTask PlansNothingBelowFirstMilestoneAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
AddWingDefinition(player, FirstTierWingNumber, Stats.PhysicalBaseDmg);
player.Attributes![Stats.Level] = 179;
var plan = BotWingHandler.PlanNextGrant(player);
Assert.That(plan, Is.Null);
}
/// <summary>
/// At the milestones the earned tier is granted with the agreed item level and option level.
/// </summary>
/// <param name="level">The character level.</param>
/// <param name="expectedNumber">The expected wing number.</param>
/// <param name="expectedItemLevel">The expected item level of the grant.</param>
/// <param name="expectedOptionLevel">The expected level of the wing option.</param>
[TestCase(180, FirstTierWingNumber, 0, 3)]
[TestCase(280, SecondTierWingNumber, 9, 4)]
[TestCase(400, ThirdTierWingNumber, 15, 4)]
public async ValueTask GrantsEarnedTierAtMilestoneAsync(int level, short expectedNumber, byte expectedItemLevel, int expectedOptionLevel)
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
AddWingDefinition(player, FirstTierWingNumber, Stats.PhysicalBaseDmg);
AddWingDefinition(player, SecondTierWingNumber, Stats.PhysicalBaseDmg);
AddWingDefinition(player, ThirdTierWingNumber, Stats.PhysicalBaseDmg);
player.Attributes![Stats.Level] = level;
var plan = BotWingHandler.PlanNextGrant(player);
Assert.That(plan, Is.Not.Null);
Assert.That(plan!.Value.Definition.Number, Is.EqualTo(expectedNumber));
Assert.That(plan.Value.ItemLevel, Is.EqualTo(expectedItemLevel));
Assert.That(plan.Value.OptionLevel, Is.EqualTo(expectedOptionLevel));
}
/// <summary>
/// A bot re-levelling through the lower milestones after a reset keeps its better wings.
/// </summary>
[Test]
public async ValueTask NeverDowngradesWornWingsAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
AddWingDefinition(player, FirstTierWingNumber, Stats.PhysicalBaseDmg);
var secondTier = AddWingDefinition(player, SecondTierWingNumber, Stats.PhysicalBaseDmg);
await WearWingsAsync(player, secondTier, 9).ConfigureAwait(false);
player.Attributes![Stats.Level] = 200;
var plan = BotWingHandler.PlanNextGrant(player);
Assert.That(plan, Is.Null);
}
/// <summary>
/// Wearing the earned tier already: nothing to do.
/// </summary>
[Test]
public async ValueTask PlansNothingWhenEarnedTierIsWornAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var secondTier = AddWingDefinition(player, SecondTierWingNumber, Stats.PhysicalBaseDmg);
await WearWingsAsync(player, secondTier, 9).ConfigureAwait(false);
player.Attributes![Stats.Level] = 300;
var plan = BotWingHandler.PlanNextGrant(player);
Assert.That(plan, Is.Null);
}
/// <summary>
/// A bot which did not evolve into its master class yet is not qualified for the third tier
/// wings and falls back to the best qualified tier.
/// </summary>
[Test]
public async ValueTask FallsBackWhenThirdTierIsNotQualifiedAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
AddWingDefinition(player, SecondTierWingNumber, Stats.PhysicalBaseDmg);
var thirdTier = AddWingDefinition(player, ThirdTierWingNumber, Stats.PhysicalBaseDmg);
thirdTier.QualifiedCharacters.Clear();
player.Attributes![Stats.Level] = 400;
var plan = BotWingHandler.PlanNextGrant(player);
Assert.That(plan, Is.Not.Null);
Assert.That(plan!.Value.Definition.Number, Is.EqualTo(SecondTierWingNumber));
Assert.That(plan.Value.Tier, Is.EqualTo(2));
}
/// <summary>
/// The capes are the only pre-master wing of their classes: granted at the first milestone at +0
/// and re-granted as a fresh +9 cape at the second one.
/// </summary>
/// <param name="wornCapeLevel">The item level of the worn cape.</param>
/// <param name="expectsGrant">Whether a new cape is expected.</param>
[TestCase(0, true)]
[TestCase(9, false)]
public async ValueTask RegrantsCapeAtSecondMilestoneAsync(byte wornCapeLevel, bool expectsGrant)
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var cape = AddWingDefinition(player, CapeNumber, Stats.PhysicalBaseDmg, CapeGroup);
await WearWingsAsync(player, cape, wornCapeLevel).ConfigureAwait(false);
player.Attributes![Stats.Level] = 280;
var plan = BotWingHandler.PlanNextGrant(player);
Assert.That(plan.HasValue, Is.EqualTo(expectsGrant));
if (expectsGrant)
{
Assert.That(plan!.Value.Definition, Is.SameAs(cape));
Assert.That(plan.Value.ItemLevel, Is.EqualTo(9));
}
}
/// <summary>
/// When a class qualifies for more than one pair (the Magic Gladiator may wear both Wings of
/// Heaven and Satan), the pair whose option matches the fighting style wins.
/// </summary>
/// <param name="baseEnergy">The bot's base energy (base strength is 28).</param>
/// <param name="expectedNumber">The expected wing number.</param>
[TestCase(200, 1)]
[TestCase(0, 2)]
public async ValueTask PrefersWingsMatchingFightingStyleAsync(int baseEnergy, short expectedNumber)
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
AddWingDefinition(player, 1, Stats.WizardryBaseDmg);
AddWingDefinition(player, 2, Stats.PhysicalBaseDmg);
player.Attributes![Stats.BaseEnergy] = baseEnergy;
player.Attributes[Stats.Level] = 180;
var plan = BotWingHandler.PlanNextGrant(player);
Assert.That(plan, Is.Not.Null);
Assert.That(plan!.Value.Definition.Number, Is.EqualTo(expectedNumber));
}
private static ItemDefinition AddWingDefinition(Player player, short number, AttributeDefinition optionTarget, byte group = 12)
{
var definitionMock = new Mock<ItemDefinition>();
definitionMock.SetupAllProperties();
definitionMock.Setup(d => d.QualifiedCharacters).Returns(new List<CharacterClass>());
definitionMock.Setup(d => d.PossibleItemOptions).Returns(new List<ItemOptionDefinition>());
var slotType = new Mock<ItemSlotType>();
slotType.Setup(s => s.ItemSlots).Returns(new List<int> { InventoryConstants.WingsSlot });
definitionMock.Setup(d => d.ItemSlot).Returns(slotType.Object);
var definition = definitionMock.Object;
definition.Group = group;
definition.Number = number;
definition.Width = 5;
definition.Height = 3;
definition.Durability = 200;
definition.MaximumItemLevel = 15;
definition.QualifiedCharacters.Add(player.SelectedCharacter!.CharacterClass!);
var optionDefinitionMock = new Mock<ItemOptionDefinition>();
optionDefinitionMock.SetupAllProperties();
optionDefinitionMock.Setup(o => o.PossibleOptions).Returns(new List<IncreasableItemOption>());
var optionMock = new Mock<IncreasableItemOption>();
optionMock.SetupAllProperties();
var powerUpMock = new Mock<PowerUpDefinition>();
powerUpMock.SetupAllProperties();
powerUpMock.Object.TargetAttribute = optionTarget;
optionMock.Object.OptionType = ItemOptionTypes.Option;
optionMock.Object.PowerUpDefinition = powerUpMock.Object;
optionDefinitionMock.Object.PossibleOptions.Add(optionMock.Object);
definition.PossibleItemOptions.Add(optionDefinitionMock.Object);
player.GameContext.Configuration.Items.Add(definition);
return definition;
}
private static async ValueTask<Item> WearWingsAsync(Player player, ItemDefinition definition, byte level)
{
var itemMock = new Mock<Item>();
itemMock.SetupAllProperties();
itemMock.Setup(i => i.ItemOptions).Returns(new List<ItemOptionLink>());
itemMock.Setup(i => i.ItemSetGroups).Returns(new List<ItemOfItemSet>());
var item = itemMock.Object;
item.Definition = definition;
item.Level = level;
item.Durability = definition.Durability;
await player.Inventory!.AddItemAsync(InventoryConstants.WingsSlot, item).ConfigureAwait(false);
return item;
}
}

View File

@@ -0,0 +1,267 @@
// <copyright file="BotMasterHandlerTests.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests.Offline;
using Moq;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.Resets;
/// <summary>
/// Tests for <see cref="BotMasterHandler"/>: when a bot evolves into its master class (including the
/// iron rule of reset servers) and which master skill it invests its points into. The point investment
/// itself goes through the regular <see cref="MUnique.OpenMU.GameLogic.PlayerActions.Character.AddMasterPointAction"/>,
/// whose rules are covered by <see cref="MasterSystemTest"/>.
/// </summary>
[TestFixture]
public class BotMasterHandlerTests
{
private IGameContext _gameContext = null!;
/// <summary>
/// Sets up a fresh game context with the usual maximum level before each test.
/// </summary>
[SetUp]
public void SetUp()
{
this._gameContext = GameContextTestHelper.CreateGameContext();
this._gameContext.Configuration.MaximumLevel = 400;
}
/// <summary>
/// Without the reset feature the evolution is due exactly at the game's maximum level.
/// </summary>
/// <param name="level">The character level.</param>
/// <param name="expectsDue">Whether the evolution is expected to be due.</param>
[TestCase(399, false)]
[TestCase(400, true)]
public async ValueTask EvolutionIsDueAtMaximumLevelAsync(int level, bool expectsDue)
{
var player = await this.CreatePlayerWithMasterTargetAsync().ConfigureAwait(false);
player.Attributes![Stats.Level] = level;
Assert.That(BotMasterHandler.IsMasterEvolutionDue(player), Is.EqualTo(expectsDue));
}
/// <summary>
/// A class which is already a master (or has no master target) never evolves again.
/// </summary>
[Test]
public async ValueTask NoEvolutionWithoutMasterTargetAsync()
{
var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
player.Attributes![Stats.Level] = 400;
Assert.That(BotMasterHandler.IsMasterEvolutionDue(player), Is.False);
}
/// <summary>
/// The iron rule of reset servers: the evolution is only due once the reset limit is exhausted,
/// and with no limit configured (resetting forever is the endgame) it is never due at all.
/// Uses the plain test context, where the added feature plugin is the effective one (see the
/// remarks at <see cref="BotResetHandlerTests.EffectiveLevelCountsResetsAsLevelSpansAsync"/>).
/// </summary>
/// <param name="resetLimit">The configured reset limit; 0 means no limit.</param>
/// <param name="resets">The bot's performed resets.</param>
/// <param name="expectsDue">Whether the evolution is expected to be due.</param>
[TestCase(3, 2, false)]
[TestCase(3, 3, true)]
[TestCase(0, 50, false)]
public async ValueTask EvolutionOnResetServersOnlyAfterLastResetAsync(int resetLimit, int resets, bool expectsDue)
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
player.GameContext.Configuration.MaximumLevel = 400;
GiveMasterTarget(player);
player.GameContext.FeaturePlugIns.AddPlugIn(
new ResetFeaturePlugIn { Configuration = new ResetConfiguration { RequiredLevel = 400, ResetLimit = resetLimit } },
true);
player.Attributes![Stats.Level] = 400;
player.Attributes[Stats.Resets] = resets;
Assert.That(BotMasterHandler.IsMasterEvolutionDue(player), Is.EqualTo(expectsDue));
}
/// <summary>
/// A due evolution assigns the master class - the same assignment the master quest performs.
/// </summary>
[Test]
public async ValueTask EvolutionAssignsMasterClassAsync()
{
var player = await this.CreatePlayerWithMasterTargetAsync().ConfigureAwait(false);
var masterClass = player.SelectedCharacter!.CharacterClass!.NextGenerationClass!;
player.Attributes![Stats.Level] = 400;
var evolved = await BotMasterHandler.TryEvolveAsync(player).ConfigureAwait(false);
Assert.That(evolved, Is.True);
Assert.That(player.SelectedCharacter!.CharacterClass, Is.SameAs(masterClass));
}
/// <summary>
/// The point spending loop learns the picked skill through the regular action and invests all
/// available points.
/// </summary>
[Test]
public async ValueTask SpendsPointsThroughRegularActionAsync()
{
// The plain test context is required here - its configuration accepts the mocked skills.
var contextDonor = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(contextDonor.GameContext).ConfigureAwait(false);
player.SelectedCharacter!.CharacterClass!.IsMasterClass = true;
var skill = this.CreateMasterSkill(1, rank: 1, player.SelectedCharacter.CharacterClass);
player.GameContext.Configuration.Skills.Add(skill);
player.SelectedCharacter.MasterLevelUpPoints = 3;
await BotMasterHandler.TrySpendMasterPointsAsync(player).ConfigureAwait(false);
Assert.That(player.SelectedCharacter.MasterLevelUpPoints, Is.Zero);
var learned = player.SelectedCharacter.LearnedSkills.FirstOrDefault(l => l.Skill == skill);
Assert.That(learned, Is.Not.Null);
Assert.That(learned!.Level, Is.EqualTo(3));
}
/// <summary>
/// A started skill is pushed to the rank-unlock level of 10 before anything new is learned.
/// </summary>
[Test]
public async ValueTask FinishesStartedSkillBeforeLearningNewAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var characterClass = player.SelectedCharacter!.CharacterClass!;
var startedSkill = this.CreateMasterSkill(1, rank: 1, characterClass);
var otherSkill = this.CreateMasterSkill(2, rank: 1, characterClass);
player.GameContext.Configuration.Skills.Add(startedSkill);
player.GameContext.Configuration.Skills.Add(otherSkill);
player.SelectedCharacter.LearnedSkills.Add(new SkillEntry { Skill = startedSkill, Level = 5 });
player.SelectedCharacter.MasterLevelUpPoints = 1;
Assert.That(BotMasterHandler.PickNextMasterSkill(player), Is.SameAs(startedSkill));
}
/// <summary>
/// A skill of the next rank only becomes eligible once a skill of the previous rank of the same
/// root reached level 10; a next-rank skill of another root stays out of reach and the points go
/// into pumping the finished skill instead.
/// </summary>
/// <param name="sameRoot">Whether the rank-2 skill shares the root of the learned rank-1 skill.</param>
[TestCase(true)]
[TestCase(false)]
public async ValueTask RespectsRankGatePerRootAsync(bool sameRoot)
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var characterClass = player.SelectedCharacter!.CharacterClass!;
var rank1 = this.CreateMasterSkill(1, rank: 1, characterClass, rootId: 1);
var rank2 = this.CreateMasterSkill(2, rank: 2, characterClass, rootId: sameRoot ? (byte)1 : (byte)2);
player.GameContext.Configuration.Skills.Add(rank1);
player.GameContext.Configuration.Skills.Add(rank2);
player.SelectedCharacter.LearnedSkills.Add(new SkillEntry { Skill = rank1, Level = 10 });
player.SelectedCharacter.MasterLevelUpPoints = 1;
var pick = BotMasterHandler.PickNextMasterSkill(player);
Assert.That(pick, Is.SameAs(sameRoot ? rank2 : rank1));
}
/// <summary>
/// Among equally reachable new skills, a "useful" one (here: a passive boosting a stat) is
/// preferred even when a useless one comes first by number.
/// </summary>
[Test]
public async ValueTask PrefersUsefulSkillAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var characterClass = player.SelectedCharacter!.CharacterClass!;
var uselessSkill = this.CreateMasterSkill(1, rank: 1, characterClass);
var passiveSkill = this.CreateMasterSkill(2, rank: 1, characterClass);
passiveSkill.MasterDefinition!.TargetAttribute = Stats.MaximumHealth;
player.GameContext.Configuration.Skills.Add(uselessSkill);
player.GameContext.Configuration.Skills.Add(passiveSkill);
player.SelectedCharacter.MasterLevelUpPoints = 1;
Assert.That(BotMasterHandler.PickNextMasterSkill(player), Is.SameAs(passiveSkill));
}
/// <summary>
/// A bonus which only applies against other players does nothing for a bot: it spends its life
/// hunting monsters. It is picked last, after a passive which helps it there.
/// </summary>
[Test]
public async ValueTask PrefersPvmBonusOverPvpBonusAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var characterClass = player.SelectedCharacter!.CharacterClass!;
var pvpSkill = this.CreateMasterSkill(1, rank: 1, characterClass);
pvpSkill.MasterDefinition!.TargetAttribute = Stats.DefenseRatePvp;
var pvmSkill = this.CreateMasterSkill(2, rank: 1, characterClass);
pvmSkill.MasterDefinition!.TargetAttribute = Stats.MaximumHealth;
player.GameContext.Configuration.Skills.Add(pvpSkill);
player.GameContext.Configuration.Skills.Add(pvmSkill);
player.SelectedCharacter.MasterLevelUpPoints = 1;
Assert.That(BotMasterHandler.PickNextMasterSkill(player), Is.SameAs(pvmSkill));
}
/// <summary>
/// With everything learned at its maximum nothing is picked - the loop stops.
/// </summary>
[Test]
public async ValueTask PicksNothingWhenTreeIsFullAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var characterClass = player.SelectedCharacter!.CharacterClass!;
var skill = this.CreateMasterSkill(1, rank: 1, characterClass);
player.GameContext.Configuration.Skills.Add(skill);
player.SelectedCharacter.LearnedSkills.Add(new SkillEntry { Skill = skill, Level = 20 });
player.SelectedCharacter.MasterLevelUpPoints = 5;
Assert.That(BotMasterHandler.PickNextMasterSkill(player), Is.Null);
}
/// <summary>
/// Creates an offline test player whose class has a master class as next generation.
/// </summary>
private async ValueTask<GameLogic.Offline.OfflinePlayer> CreatePlayerWithMasterTargetAsync()
{
var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
GiveMasterTarget(player);
return player;
}
/// <summary>
/// Gives the player's character class a master class as next generation.
/// </summary>
private static void GiveMasterTarget(Player player)
{
var masterClass = new CharacterClass
{
Name = "Test Master",
IsMasterClass = true,
};
Mock.Get(player.SelectedCharacter!.CharacterClass!)
.Setup(c => c.NextGenerationClass)
.Returns(masterClass);
}
private Skill CreateMasterSkill(short number, byte rank, CharacterClass qualifiedClass, byte rootId = 1)
{
var masterDefinition = new Mock<MasterSkillDefinition>();
masterDefinition.SetupAllProperties();
masterDefinition.Object.Rank = rank;
masterDefinition.Object.MaximumLevel = 20;
masterDefinition.Object.MinimumLevel = 1;
masterDefinition.Object.Root = new MasterSkillRoot { Id = new Guid(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, rootId) };
masterDefinition.Setup(m => m.RequiredMasterSkills).Returns(new List<Skill>());
var skill = new Mock<Skill>();
skill.SetupAllProperties();
skill.Object.Number = number;
skill.Setup(s => s.QualifiedCharacters).Returns(new List<CharacterClass> { qualifiedClass });
skill.Object.MasterDefinition = masterDefinition.Object;
return skill.Object;
}
}

View File

@@ -0,0 +1,79 @@
// <copyright file="BotProgressionTests.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests.Offline;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Bots;
/// <summary>
/// Tests for <see cref="BotProgression"/>: the point split with capacities and the per-bot rolls.
/// </summary>
[TestFixture]
public class BotProgressionTests
{
/// <summary>
/// Tests that the split assigns all points proportionally when nothing is capped.
/// </summary>
[Test]
public void SplitPoints_AssignsAllPointsProportionally()
{
var weights = new[] { (Stats.BaseStrength, 60), (Stats.BaseAgility, 35), (Stats.BaseVitality, 5) };
var result = BotProgression.SplitPoints(1000, weights).ToDictionary(r => r.Stat, r => r.Amount);
Assert.That(result.Values.Sum(), Is.EqualTo(1000));
Assert.That(result[Stats.BaseAgility], Is.EqualTo(350));
Assert.That(result[Stats.BaseVitality], Is.EqualTo(50));
Assert.That(result[Stats.BaseStrength], Is.EqualTo(600));
}
/// <summary>
/// Tests that a capped stat drops out of the split and its share flows to the remaining stats.
/// </summary>
[Test]
public void SplitPoints_CappedStatOverflowsToOthers()
{
var weights = new[] { (Stats.BaseStrength, 60), (Stats.BaseAgility, 35), (Stats.BaseVitality, 5) };
long CapacityOf(AttributeDefinition stat) => stat == Stats.BaseVitality ? 10 : long.MaxValue;
var result = BotProgression.SplitPoints(1000, weights, CapacityOf).ToDictionary(r => r.Stat, r => r.Amount);
Assert.That(result[Stats.BaseVitality], Is.EqualTo(10));
Assert.That(result.Values.Sum(), Is.EqualTo(1000));
Assert.That(result[Stats.BaseStrength] + result[Stats.BaseAgility], Is.EqualTo(990));
}
/// <summary>
/// Tests that points stay unassigned when every stat is at its capacity, like for a maxed character.
/// </summary>
[Test]
public void SplitPoints_AllCapped_LeavesPointsUnassigned()
{
var weights = new[] { (Stats.BaseStrength, 60), (Stats.BaseAgility, 40) };
// Every stat is capped at the same value, so which one is asked for does not matter.
Func<AttributeDefinition, long> capacityOf = _ => 25;
var result = BotProgression.SplitPoints(1000, weights, capacityOf).ToDictionary(r => r.Stat, r => r.Amount);
Assert.That(result.Values.Sum(), Is.EqualTo(50));
Assert.That(result.Values, Is.All.EqualTo(25));
}
/// <summary>
/// Tests that the vitality target roll stays within 100..500 and is stable for the same name.
/// </summary>
[Test]
public void GetVitalityTarget_IsStableAndWithinRange()
{
foreach (var name in new[] { "Kaeoris", "Milynara", "Hallin", "Oriwen", "X" })
{
var target = BotProgression.GetVitalityTarget(name);
Assert.That(target, Is.InRange(100, 500), name);
Assert.That(BotProgression.GetVitalityTarget(name), Is.EqualTo(target), name);
}
}
}

View File

@@ -0,0 +1,110 @@
// <copyright file="BotResetHandlerTests.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests.Offline;
using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.Resets;
/// <summary>
/// Tests for <see cref="BotResetHandler"/>.
/// </summary>
[TestFixture]
public class BotResetHandlerTests
{
private IGameContext _gameContext = null!;
/// <summary>
/// Sets up a fresh game context before each test.
/// </summary>
[SetUp]
public void SetUp()
{
this._gameContext = GameContextTestHelper.CreateGameContext();
}
/// <summary>
/// Without the reset feature the effective level is simply the character level.
/// </summary>
[Test]
public async ValueTask EffectiveLevelWithoutResetFeatureIsPlainLevelAsync()
{
var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
player.Attributes![Stats.Level] = 123;
Assert.That(BotResetHandler.GetEffectiveLevel(player), Is.EqualTo(123));
}
/// <summary>
/// With the reset feature every reset counts as the configured level span. Uses the same plain
/// test context as <see cref="ResetCharacterActionTest"/>, where the added feature plugin is the
/// effective one (the offline helper's context discovers the real, disabled-by-default plugin).
/// </summary>
[Test]
public async ValueTask EffectiveLevelCountsResetsAsLevelSpansAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
player.GameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = new ResetConfiguration { RequiredLevel = 400 } }, true);
player.Attributes![Stats.Level] = 50;
player.Attributes[Stats.Resets] = 3;
Assert.That(BotResetHandler.GetEffectiveLevel(player), Is.EqualTo((3 * 400) + 50));
}
/// <summary>
/// A due reset raises the reset count, drops the level and grants the configured points, without
/// consuming any costs when the bot doesn't pay them.
/// </summary>
[Test]
public async ValueTask TryResetPerformsResetAndSkipsCostsAsync()
{
var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
var configuration = new ResetConfiguration
{
RequiredLevel = 400,
LevelAfterReset = 10,
RequiredMoney = 500,
MultiplyRequiredMoneyByResetCount = false,
PointsPerReset = 1000,
MultiplyPointsByResetCount = false,
ReplacePointsPerReset = true,
ResetStats = false,
MoveHome = false,
LogOut = false,
};
this._gameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = configuration }, true);
player.Attributes![Stats.Level] = 400;
player.Money = 100; // less than the required zen - must not matter for a non-paying bot
var performed = await BotResetHandler.TryResetAsync(player, configuration, payCosts: false).ConfigureAwait(false);
Assert.That(performed, Is.True);
Assert.That((int)player.Attributes[Stats.Resets], Is.EqualTo(1));
Assert.That((int)player.Attributes[Stats.Level], Is.EqualTo(10));
Assert.That(player.SelectedCharacter!.LevelUpPoints, Is.EqualTo(1000));
Assert.That(player.SelectedCharacter.Experience, Is.EqualTo(0));
Assert.That(player.Money, Is.EqualTo(100));
}
/// <summary>
/// A reset is not performed below the required level or beyond the reset limit.
/// </summary>
[Test]
public async ValueTask TryResetRespectsLevelAndLimitAsync()
{
var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
var configuration = new ResetConfiguration { RequiredLevel = 400, ResetLimit = 2, MoveHome = false, LogOut = false };
this._gameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = configuration }, true);
player.Attributes![Stats.Level] = 399;
Assert.That(await BotResetHandler.TryResetAsync(player, configuration, payCosts: false).ConfigureAwait(false), Is.False);
player.Attributes[Stats.Level] = 400;
player.Attributes[Stats.Resets] = 2;
Assert.That(await BotResetHandler.TryResetAsync(player, configuration, payCosts: false).ConfigureAwait(false), Is.False);
Assert.That((int)player.Attributes[Stats.Resets], Is.EqualTo(2));
}
}

View File

@@ -50,11 +50,17 @@ public class CombatHandlerTests
await player.CurrentMap!.AddAsync(monster).ConfigureAwait(false);
var config = new MuHelperSettings { HuntingRange = 10 };
var movementHandler = new MovementHandler(player, config, this._origin);
player.HuntingOrigin = this._origin;
var movementHandler = new MovementHandler(player, config);
var handler = new CombatHandler(player, config, movementHandler, this._origin);
var handler = new CombatHandler(player, config, movementHandler);
// Act
// The first call only acquires the target and turns towards it: a fresh target gets a small
// randomized human-like reaction delay (up to 900 ms) before the bot engages, so we call
// again after the delay has certainly elapsed.
await handler.PerformAttackAsync().ConfigureAwait(false);
await Task.Delay(1000).ConfigureAwait(false);
await handler.PerformAttackAsync().ConfigureAwait(false);
// Assert
@@ -93,9 +99,10 @@ public class CombatHandlerTests
};
await player.SkillList!.AddLearnedSkillAsync(drainSkill).ConfigureAwait(false);
var movementHandler = new MovementHandler(player, config, this._origin);
player.HuntingOrigin = this._origin;
var movementHandler = new MovementHandler(player, config);
var handler = new CombatHandler(player, config, movementHandler, this._origin);
var handler = new CombatHandler(player, config, movementHandler);
// Act
await handler.PerformDrainLifeRecoveryAsync().ConfigureAwait(false);

View File

@@ -44,7 +44,8 @@ public class MovementHandlerTests
HuntingRange = 5,
};
var handler = new MovementHandler(player, config, origin);
player.HuntingOrigin = origin;
var handler = new MovementHandler(player, config);
// Act
var result = await handler.RegroupAsync().ConfigureAwait(false);

View File

@@ -0,0 +1,204 @@
// <copyright file="BotPartyHandlerTest.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests;
using Moq;
using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions.Party;
/// <summary>
/// Tests <see cref="BotPartyHandler"/> - how a server-side bot answers party invitations from
/// players and when it leaves the party again.
/// </summary>
[TestFixture]
public class BotPartyHandlerTest
{
/// <summary>
/// The happy path: an eligible invitation is scheduled and, once processed, forms a party with
/// the inviter as its master.
/// </summary>
[Test]
public async ValueTask AcceptsInviteAndFormsPartyAsync()
{
var gameContext = GameContextTestHelper.CreateGameContext();
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false);
Assert.That(scheduled, Is.True);
Assert.That(bot.PendingPartyInvite, Is.Not.Null);
Assert.That(bot.LastPartyRequester, Is.SameAs(requester));
await BotPartyHandler.ProcessAsync(bot).ConfigureAwait(false);
Assert.That(bot.Party, Is.Not.Null);
Assert.That(bot.Party!.PartyMaster, Is.SameAs(requester));
Assert.That(requester.Party, Is.SameAs(bot.Party));
Assert.That(bot.PendingPartyInvite, Is.Null);
Assert.That(bot.LastPartyRequester, Is.Null);
}
/// <summary>
/// An inviter whose effective level is too far from the bot's is declined - the group would only
/// be a power-leveling service.
/// </summary>
[Test]
public async ValueTask RejectsTooLargeLevelGapAsync()
{
var gameContext = GameContextTestHelper.CreateGameContext();
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
requester.Attributes![Stats.Level] = 700;
var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false);
Assert.That(scheduled, Is.False);
Assert.That(bot.PendingPartyInvite, Is.Null);
Assert.That(bot.LastPartyRequester, Is.Null);
}
/// <summary>
/// A bot on a shopping errand declines the invitation, like a busy player would.
/// </summary>
[Test]
public async ValueTask RejectsWhileOnShoppingTripAsync()
{
var gameContext = GameContextTestHelper.CreateGameContext();
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
bot.IsOnShoppingTrip = true;
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false);
Assert.That(scheduled, Is.False);
}
/// <summary>
/// Only server-side bot accounts answer; a regular offline session of a human account does not.
/// </summary>
[Test]
public async ValueTask RejectsForNonBotAccountAsync()
{
var gameContext = GameContextTestHelper.CreateGameContext();
var bot = await CreateBotAsync(gameContext, "Bot", isBot: false).ConfigureAwait(false);
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false);
Assert.That(scheduled, Is.False);
}
/// <summary>
/// The invitation is re-validated when the delay passed: an inviter who joined another party as a
/// plain member in the meantime cannot take the bot in anymore.
/// </summary>
[Test]
public async ValueTask CancelsWhenRequesterJoinedAnotherPartyAsync()
{
var gameContext = GameContextTestHelper.CreateGameContext();
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
var thirdPlayer = await CreateHumanAsync(gameContext, "Third").ConfigureAwait(false);
var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false);
Assert.That(scheduled, Is.True);
// Meanwhile the inviter joins another party as a plain member (the third player is master).
var otherParty = gameContext.PartyManager.CreateParty();
await otherParty.AddAsync(thirdPlayer).ConfigureAwait(false);
await otherParty.AddAsync(requester).ConfigureAwait(false);
await BotPartyHandler.ProcessAsync(bot).ConfigureAwait(false);
Assert.That(bot.Party, Is.Null);
Assert.That(bot.PendingPartyInvite, Is.Null);
Assert.That(bot.LastPartyRequester, Is.Null);
Assert.That(otherParty.PartyList, Does.Not.Contain(bot));
}
/// <summary>
/// After its rolled party time is up, the bot gets bored of the party with a human and leaves.
/// </summary>
[Test]
public async ValueTask LeavesPartyWithHumanWhenBoredAsync()
{
var gameContext = GameContextTestHelper.CreateGameContext();
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
var party = gameContext.PartyManager.CreateParty();
await party.AddAsync(requester).ConfigureAwait(false);
await party.AddAsync(bot).ConfigureAwait(false);
bot.PartyBoredomAtUtc = DateTime.UtcNow - TimeSpan.FromSeconds(1);
await BotPartyHandler.ProcessAsync(bot).ConfigureAwait(false);
Assert.That(bot.Party, Is.Null);
Assert.That(bot.PartyBoredomAtUtc, Is.Null);
}
/// <summary>
/// Bot-only parties are managed by the hourly re-formation instead - no boredom timer runs, and
/// the bot stays with its group.
/// </summary>
[Test]
public async ValueTask StaysInBotOnlyPartyAsync()
{
var gameContext = GameContextTestHelper.CreateGameContext();
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
var otherBot = await CreateBotAsync(gameContext, "OtherBot").ConfigureAwait(false);
var party = gameContext.PartyManager.CreateParty();
await party.AddAsync(otherBot).ConfigureAwait(false);
await party.AddAsync(bot).ConfigureAwait(false);
bot.PartyBoredomAtUtc = DateTime.UtcNow - TimeSpan.FromSeconds(1);
await BotPartyHandler.ProcessAsync(bot).ConfigureAwait(false);
Assert.That(bot.Party, Is.SameAs(party));
Assert.That(bot.PartyBoredomAtUtc, Is.Null);
}
/// <summary>
/// The full wiring: a party request through the regular request action reaches the bot via the
/// <see cref="GameLogic.MuHelper.PartyRequestHandler"/> criteria and schedules the delayed answer.
/// </summary>
[Test]
public async ValueTask PartyRequestActionSchedulesInviteForBotAsync()
{
var gameContext = GameContextTestHelper.CreateGameContext();
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
requester.Observers.Add(bot);
var action = new PartyRequestAction();
await action.HandlePartyRequestAsync(requester, bot).ConfigureAwait(false);
Assert.That(bot.PendingPartyInvite, Is.Not.Null);
Assert.That(bot.PendingPartyInvite!.Requester, Is.SameAs(requester));
Assert.That(bot.LastPartyRequester, Is.SameAs(requester));
}
private static async ValueTask<OfflinePlayer> CreateBotAsync(IGameContext gameContext, string name, bool isBot = true)
{
var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false);
await bot.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
bot.SelectedCharacter!.Name = name;
bot.IsAlive = true;
bot.Account!.IsBot = isBot;
bot.MuHelperSettings = new BotMuHelperSettings();
return bot;
}
private static async ValueTask<Player> CreateHumanAsync(IGameContext gameContext, string name)
{
var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
player.SelectedCharacter!.Name = name;
player.IsAlive = true;
return player;
}
}