Merge pull request #820 from nolt/feature-bots
(cherry picked from commit b10de0645a869485fbb5771a739abd06b5708c2d)
This commit is contained in:
257
docs/Bots.md
Normal file
257
docs/Bots.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
122
src/GameLogic/Bots/BotConfiguration.cs
Normal file
122
src/GameLogic/Bots/BotConfiguration.cs
Normal 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();
|
||||
}
|
||||
283
src/GameLogic/Bots/BotEquipmentHandler.cs
Normal file
283
src/GameLogic/Bots/BotEquipmentHandler.cs
Normal 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);
|
||||
}
|
||||
576
src/GameLogic/Bots/BotFeaturePlugIn.cs
Normal file
576
src/GameLogic/Bots/BotFeaturePlugIn.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
606
src/GameLogic/Bots/BotGenerator.cs
Normal file
606
src/GameLogic/Bots/BotGenerator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
228
src/GameLogic/Bots/BotJewelHandler.cs
Normal file
228
src/GameLogic/Bots/BotJewelHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
310
src/GameLogic/Bots/BotManager.cs
Normal file
310
src/GameLogic/Bots/BotManager.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
351
src/GameLogic/Bots/BotMasterHandler.cs
Normal file
351
src/GameLogic/Bots/BotMasterHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
177
src/GameLogic/Bots/BotMiniGameHandler.cs
Normal file
177
src/GameLogic/Bots/BotMiniGameHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
212
src/GameLogic/Bots/BotMuHelperSettings.cs
Normal file
212
src/GameLogic/Bots/BotMuHelperSettings.cs
Normal 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;
|
||||
}
|
||||
92
src/GameLogic/Bots/BotNameGenerator.cs
Normal file
92
src/GameLogic/Bots/BotNameGenerator.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
1626
src/GameLogic/Bots/BotNavigator.cs
Normal file
1626
src/GameLogic/Bots/BotNavigator.cs
Normal file
File diff suppressed because it is too large
Load Diff
222
src/GameLogic/Bots/BotPartyHandler.cs
Normal file
222
src/GameLogic/Bots/BotPartyHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
115
src/GameLogic/Bots/BotPlayer.cs
Normal file
115
src/GameLogic/Bots/BotPlayer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
433
src/GameLogic/Bots/BotProgression.cs
Normal file
433
src/GameLogic/Bots/BotProgression.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
69
src/GameLogic/Bots/BotPvpRules.cs
Normal file
69
src/GameLogic/Bots/BotPvpRules.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
218
src/GameLogic/Bots/BotResetHandler.cs
Normal file
218
src/GameLogic/Bots/BotResetHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
40
src/GameLogic/Bots/BotRevengePlugIn.cs
Normal file
40
src/GameLogic/Bots/BotRevengePlugIn.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
36
src/GameLogic/Bots/BotSelfDefensePlugIn.cs
Normal file
36
src/GameLogic/Bots/BotSelfDefensePlugIn.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
186
src/GameLogic/Bots/BotServerPartition.cs
Normal file
186
src/GameLogic/Bots/BotServerPartition.cs
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
245
src/GameLogic/Bots/BotShoppingHandler.cs
Normal file
245
src/GameLogic/Bots/BotShoppingHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
177
src/GameLogic/Bots/BotSkillProgressionPlugIn.cs
Normal file
177
src/GameLogic/Bots/BotSkillProgressionPlugIn.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
263
src/GameLogic/Bots/BotWingHandler.cs
Normal file
263
src/GameLogic/Bots/BotWingHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/GameLogic/Bots/PendingPartyInvite.cs
Normal file
13
src/GameLogic/Bots/PendingPartyInvite.cs
Normal 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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
5243
src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.Designer.cs
generated
Normal file
5243
src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
177
tests/MUnique.OpenMU.Tests/BotEquipmentHandlerTest.cs
Normal file
177
tests/MUnique.OpenMU.Tests/BotEquipmentHandlerTest.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
197
tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs
Normal file
197
tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
175
tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs
Normal file
175
tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
49
tests/MUnique.OpenMU.Tests/BotSelfHealingTest.cs
Normal file
49
tests/MUnique.OpenMU.Tests/BotSelfHealingTest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
123
tests/MUnique.OpenMU.Tests/BotServerPartitionTest.cs
Normal file
123
tests/MUnique.OpenMU.Tests/BotServerPartitionTest.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
225
tests/MUnique.OpenMU.Tests/BotWingHandlerTest.cs
Normal file
225
tests/MUnique.OpenMU.Tests/BotWingHandlerTest.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
267
tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs
Normal file
267
tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
79
tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs
Normal file
79
tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
tests/MUnique.OpenMU.Tests/Offline/BotResetHandlerTests.cs
Normal file
110
tests/MUnique.OpenMU.Tests/Offline/BotResetHandlerTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
204
tests/MUnique.OpenMU.Tests/Party/BotPartyHandlerTest.cs
Normal file
204
tests/MUnique.OpenMU.Tests/Party/BotPartyHandlerTest.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user