Merge pull request #841 from nolt/bot-behaviour
Make the server-side bots hold up as a population (cherry picked from commit 88535b63a958fee9803d5fc3a4bdac5a3318d221)
This commit is contained in:
98
docs/Bots.md
98
docs/Bots.md
@@ -65,8 +65,21 @@ The *Bots* feature plugin, in the "Feature Plugins" section of the admin panel:
|
||||
- **`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.
|
||||
- **`Jewel stock per kind`** — how many Jewels of Bless, Soul and Life a bot
|
||||
keeps of each kind. Above it, it stops picking them up and sells what it
|
||||
already carries. Depends on the server's drop rates: on high rates a small
|
||||
stock keeps the backpack free, on low rates a larger one is never reached
|
||||
anyway.
|
||||
- **`Potion stock charges`** — how many charges of healing and of mana potions a
|
||||
bot restocks to at a merchant. Depends on what the shops sell: a server whose
|
||||
potions come in stacks of 255 fills the target in a single purchase.
|
||||
- **`Reset bots`** — deletes the whole population and generates it again, then
|
||||
clears itself. With the number of accounts set to zero it deletes without
|
||||
generating anything.
|
||||
- **`Purge bots`** — deletes the whole population WITHOUT generating a new one,
|
||||
and switches the feature off. It works with the feature enabled or disabled;
|
||||
the switch-off is what makes it a purge, since the same pass would otherwise
|
||||
create the population again right after deleting it. Clears itself afterwards.
|
||||
|
||||
## What a bot does
|
||||
|
||||
@@ -83,19 +96,52 @@ chance to be hit — a monster's nominal level says little about its punch on th
|
||||
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.
|
||||
Map access follows the game's warp list, and a bot travels on a player's terms:
|
||||
it enters a map only if its level may legally warp there, it meets the map's own
|
||||
requirements, and it pays the warp's fare out of its own Zen. 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.
|
||||
|
||||
Which map it goes to is drawn rather than maximized. The maps of a level band
|
||||
differ by a few monster levels, so always taking the strongest one made it every
|
||||
bot's answer and left the rest of the band deserted. The best map still wins
|
||||
about a third of the picks and the runners-up split the rest, so the population
|
||||
spreads over the maps a player of that band would choose between. Every map in
|
||||
the draw is one the bot may legally reach, can afford, and which is better than
|
||||
where it stands - the draw only decides between improvements.
|
||||
|
||||
A map can pass every check and still pay nothing: the monsters the bot may fight
|
||||
are a rare kind among ones it must refuse, or other hunters empty the grounds
|
||||
first. The bot notices the way a player would, by having landed no hit in
|
||||
minutes, and steps down to easier ground one notch at a time until it finds
|
||||
something it can farm; the regular map choice carries it back up as its level and
|
||||
gear recover. A bot which cannot afford any trip at all walks home to its class
|
||||
town for free, so it is never stranded on ground it cannot earn on.
|
||||
|
||||
### 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
|
||||
for; casters keep their distance and drink mana. Between skills worth about the
|
||||
same it takes the one with the longer reach - the flat bonus of a spell is a
|
||||
rounding error next to a high-level character's own damage, while three tiles of
|
||||
range are three tiles at any level. Skills the game only activates during a
|
||||
castle siege are left out, and so are a pet's skills unless the pet is actually
|
||||
equipped: Plasma Storm draws its damage from the Fenrir, but the attribute behind
|
||||
it is derived from the character's own stats, so nothing but the pet slot tells a
|
||||
mounted character from one riding nothing. 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.
|
||||
|
||||
A skill the character cannot currently cast is passed over, in the attack
|
||||
rotation and in the buffs alike. That is not the same as not having learned it: a
|
||||
reset keeps every skill but takes back the level which unlocked it, so a veteran
|
||||
back at level 12 still owns Swell Life, which asks for level 120. The game
|
||||
refuses such a cast silently, so a character which kept trying would simply stand
|
||||
there — buffing something that never takes effect, and never getting as far as
|
||||
attacking.
|
||||
|
||||
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
|
||||
@@ -136,13 +182,30 @@ 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.
|
||||
A merchant trip is the only moment a bot can turn loot into anything, so it goes
|
||||
whenever it has something to gain there: the backpack is filling with junk, the
|
||||
potions are running low, a jewel is waiting to be used, or a surplus is waiting
|
||||
to be sold. Restocking needs the means to pay for it, though - Zen, or loot to
|
||||
sell once it is there. A broke bot buys nothing, so the trip would leave it just
|
||||
as short as it set out, and it would set out again instead of hunting, which is
|
||||
the only way it could have earned the money. It picks the merchant which sells what it needs right now, and on a
|
||||
map whose merchants sell no potions while it needs some, it warps home to a real
|
||||
town instead. While the shop dialog visibly occupies it, the bot sells its junk,
|
||||
repairs its gear — which is what earns the NPC's discount — and buys potions and,
|
||||
where a shop offers them, jewels.
|
||||
|
||||
Only Jewels of Bless, Soul and Life are collected, and only up to the configured
|
||||
stock: a bot cannot trade or craft, so any other kind would be a backpack slot it
|
||||
never gets back. They 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.
|
||||
|
||||
A bot which reaches the server's maximum inventory money can no longer sell
|
||||
anything — the money simply does not fit. What it cannot sell it keeps, and
|
||||
destroys only what has no other way out: jewels beyond its stock, and, while the
|
||||
backpack is genuinely full, junk gear. The repair bill is what normally keeps it
|
||||
away from that limit in the first place.
|
||||
|
||||
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
|
||||
@@ -253,5 +316,10 @@ hash dominates); starting an existing one of 1100 bots takes some 15 seconds.
|
||||
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,
|
||||
4. To build a fresh population, set `Reset bots`: it deletes the old one,
|
||||
generates a new one, and clears the flag again.
|
||||
5. To stop the bots without losing them, uncheck `Enabled`: they log out within
|
||||
a few seconds and nothing is deleted, so checking it again brings the same
|
||||
characters back. To get rid of them for good, set `Purge bots` — it deletes
|
||||
every bot account with its characters, items and storages, and leaves the
|
||||
feature switched off.
|
||||
|
||||
@@ -31,6 +31,15 @@ public class BotConfiguration
|
||||
[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 all bot accounts and characters should be deleted
|
||||
/// WITHOUT being regenerated. Unlike <see cref="ResetBots"/> this also turns <see cref="Enabled"/>
|
||||
/// off - otherwise the very same pass would generate the population again - so it is the single
|
||||
/// switch for "I do not want bots on this server anymore". Clears itself afterwards.
|
||||
/// </summary>
|
||||
[Display(Name = "Purge bots", Description = "Deletes all bot accounts and characters and turns the bot feature off, without generating new ones. Clears itself afterwards.")]
|
||||
public bool PurgeBots { 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 -
|
||||
@@ -83,6 +92,26 @@ public class BotConfiguration
|
||||
[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 how many Jewels of Bless, Soul and Life a bot keeps of each kind. Bots only pick up
|
||||
/// the jewels they can actually spend on their own gear (see <c>BotJewelHandler</c>) and stop
|
||||
/// collecting a kind once they hold this many; whatever they carry above it is sold on the next
|
||||
/// merchant visit. The sensible value depends entirely on the server's drop rates - on a high rate
|
||||
/// server a bot refills a big stock within hours, so a low limit keeps its backpack usable.
|
||||
/// </summary>
|
||||
[Display(Name = "Jewel stock per kind", Description = "How many Jewels of Bless/Soul/Life a bot keeps of each kind; above this it stops picking them up and sells the surplus.")]
|
||||
[Range(0, 100)]
|
||||
public int JewelStockPerKind { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of potion charges (per healing and per mana potions) a bot stocks up to
|
||||
/// at a merchant. Merchants sell potions in stacks of different sizes, so this is the target the bot
|
||||
/// buys towards, not a stack count.
|
||||
/// </summary>
|
||||
[Display(Name = "Potion stock (charges)", Description = "How many healing and mana potion charges a bot buys up to at a merchant.")]
|
||||
[Range(10, 255)]
|
||||
public int PotionStockCharges { get; set; } = 60;
|
||||
|
||||
/// <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
|
||||
@@ -109,6 +138,22 @@ public class BotConfiguration
|
||||
public int GetEffectiveBotCapacityPercent()
|
||||
=> Math.Clamp(this.BotCapacityPercent, 1, 100);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective, clamped jewel stock a bot keeps of each usable kind.
|
||||
/// </summary>
|
||||
/// <returns>A value between 0 and 100.</returns>
|
||||
/// <remarks>Deliberately a method, like <see cref="GetEffectiveCharactersPerAccount"/>.</remarks>
|
||||
public int GetEffectiveJewelStockPerKind()
|
||||
=> Math.Clamp(this.JewelStockPerKind, 0, 100);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective, clamped potion charges a bot stocks up to.
|
||||
/// </summary>
|
||||
/// <returns>A value between 10 and 255.</returns>
|
||||
/// <remarks>Deliberately a method, like <see cref="GetEffectiveCharactersPerAccount"/>.</remarks>
|
||||
public int GetEffectivePotionStockCharges()
|
||||
=> Math.Clamp(this.PotionStockCharges, 10, 255);
|
||||
|
||||
/// <summary>
|
||||
/// Parses <see cref="ProofOfConceptAccounts"/> into the distinct, trimmed login names.
|
||||
/// </summary>
|
||||
|
||||
@@ -71,10 +71,37 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus
|
||||
/// <inheritdoc />
|
||||
public BotConfiguration? Configuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bot configuration of the given game context, so the bot handlers can read their
|
||||
/// admin-panel editable settings without being handed the plugin around (same shape as
|
||||
/// <see cref="BotResetHandler.GetResetConfiguration"/>).
|
||||
/// </summary>
|
||||
/// <param name="gameContext">The game context.</param>
|
||||
/// <returns>The configuration, or <c>null</c> when the bot feature is not configured.</returns>
|
||||
public static BotConfiguration? GetConfiguration(IGameContext gameContext)
|
||||
=> gameContext.FeaturePlugIns.GetPlugIn<BotFeaturePlugIn>()?.Configuration;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask ExecuteTaskAsync(GameContext gameContext)
|
||||
{
|
||||
var state = this._states.GetOrAdd(gameContext, _ => new ServerState());
|
||||
var configuration = this.Configuration ??= CreateDefaultConfiguration();
|
||||
|
||||
if (configuration.PurgeBots)
|
||||
{
|
||||
await this.PurgeAsync(gameContext, state, configuration).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!configuration.Enabled)
|
||||
{
|
||||
// Switching the feature off takes effect right away instead of at the next restart: the
|
||||
// bots log out, and nothing is deleted. Re-checked on the following ticks, the feature may
|
||||
// get enabled again later - then the population is spawned from scratch.
|
||||
await this.StopBotsAsync(gameContext, state, "the bot feature was switched off").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.StartupState == (int)StartupPhase.Done)
|
||||
{
|
||||
await this.RunMaintenanceAsync(gameContext, state).ConfigureAwait(false);
|
||||
@@ -87,14 +114,6 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus
|
||||
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);
|
||||
@@ -107,6 +126,114 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops every bot this server animates and puts it back to the state before the startup pass, so
|
||||
/// the population is spawned from scratch if the feature is switched on again. Nothing is deleted.
|
||||
/// </summary>
|
||||
/// <param name="gameContext">The game context.</param>
|
||||
/// <param name="state">The state of this server.</param>
|
||||
/// <param name="reason">The reason to log, if there was anything to stop.</param>
|
||||
private async ValueTask StopBotsAsync(GameContext gameContext, ServerState state, string reason)
|
||||
{
|
||||
if (state.Manager.BotCount == 0 && state.StartupState == (int)StartupPhase.NotStarted)
|
||||
{
|
||||
// The usual case of a server without bots - this runs on every tick, so it stays cheap.
|
||||
return;
|
||||
}
|
||||
|
||||
// Take over the startup state machine, so the bots are not stopped while a startup pass is
|
||||
// still spawning them (it would spawn into the emptied manager afterwards).
|
||||
if (Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.Done) != (int)StartupPhase.Done
|
||||
&& Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.NotStarted) != (int)StartupPhase.NotStarted)
|
||||
{
|
||||
// A startup pass is running; retried on one of the next ticks.
|
||||
return;
|
||||
}
|
||||
|
||||
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
|
||||
using var scope = logger.BeginScope(gameContext);
|
||||
try
|
||||
{
|
||||
var stopped = state.Manager.BotCount;
|
||||
await state.Manager.StopAllAsync().ConfigureAwait(false);
|
||||
state.PendingRespawns.Clear();
|
||||
if (stopped > 0)
|
||||
{
|
||||
logger.LogInformation("Stopped {Stopped} bot(s): {Reason}.", stopped, reason);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to stop the bots.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.NotStarted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carries out a requested purge: every bot account is deleted and the feature switches itself off,
|
||||
/// so the population is NOT generated again - the difference to <see cref="BotConfiguration.ResetBots"/>.
|
||||
/// Works with the feature enabled or disabled, and whatever number of accounts is configured.
|
||||
/// </summary>
|
||||
/// <param name="gameContext">The game context.</param>
|
||||
/// <param name="state">The state of this server.</param>
|
||||
/// <param name="configuration">The bot configuration.</param>
|
||||
private async ValueTask PurgeAsync(GameContext gameContext, ServerState state, BotConfiguration configuration)
|
||||
{
|
||||
if (DateTime.UtcNow < state.NextRunUtc)
|
||||
{
|
||||
// The same head start the startup pass gets - and where a failed purge backs off to.
|
||||
return;
|
||||
}
|
||||
|
||||
// The bots have to be gone before their accounts are: one which is still online would go on
|
||||
// saving a character whose row is about to be deleted.
|
||||
await this.StopBotsAsync(gameContext, state, "the bot population is being purged").ConfigureAwait(false);
|
||||
if (state.Manager.BotCount > 0
|
||||
|| Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.NotStarted) != (int)StartupPhase.NotStarted)
|
||||
{
|
||||
// A startup pass still holds the state machine; retried on one of the next ticks.
|
||||
return;
|
||||
}
|
||||
|
||||
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
|
||||
using var scope = logger.BeginScope(gameContext);
|
||||
try
|
||||
{
|
||||
var partition = await BotServerPartition.CreateAsync(gameContext, configuration, logger).ConfigureAwait(false);
|
||||
if (!partition.IsGenerator)
|
||||
{
|
||||
// Another server deletes the accounts and clears the flags. This one only had to stop
|
||||
// its own bots; the switched-off feature keeps it from starting them again.
|
||||
return;
|
||||
}
|
||||
|
||||
var generator = new BotGenerator(gameContext, logger);
|
||||
var deleted = await generator.DeleteAllBotsAsync().ConfigureAwait(false);
|
||||
|
||||
// Switching the feature off is what makes this a purge instead of a reset: the generation
|
||||
// below would otherwise create the whole population again, right after it was deleted.
|
||||
configuration.Enabled = false;
|
||||
configuration.PurgeBots = false;
|
||||
configuration.ResetBots = false;
|
||||
await this.PersistConfigurationAsync(gameContext, configuration, logger).ConfigureAwait(false);
|
||||
|
||||
logger.LogInformation("Purge requested: deleted {Deleted} bot account(s) and switched the bot feature off.", deleted);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The flag stays set, so the purge is retried - backed off, to not repeat it every second.
|
||||
state.NextRunUtc = DateTime.UtcNow + MaintenanceInterval;
|
||||
logger.LogError(ex, "Failed to purge the bot population.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.NotStarted);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask SpawnPopulationAsync(GameContext gameContext, ServerState state, BotConfiguration configuration)
|
||||
{
|
||||
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
|
||||
@@ -115,12 +242,18 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus
|
||||
var generator = new BotGenerator(gameContext, logger);
|
||||
var partition = state.Partition = await BotServerPartition.CreateAsync(gameContext, configuration, logger).ConfigureAwait(false);
|
||||
|
||||
if (configuration.ResetBots && !partition.IsGenerator)
|
||||
{
|
||||
// Not silent: the flag is set, but this server is not the one which acts on it.
|
||||
logger.LogInformation("Reset requested: another game server of the deployment carries it out.");
|
||||
}
|
||||
|
||||
if (configuration.ResetBots && partition.IsGenerator)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deleted = await generator.DeleteAllBotsAsync().ConfigureAwait(false);
|
||||
logger.LogInformation("Reset requested: purged {Deleted} bot account(s).", deleted);
|
||||
logger.LogInformation("Reset requested: deleted {Deleted} bot account(s); {Requested} account(s) are generated again.", deleted, Math.Max(configuration.NumberOfAccounts, 0));
|
||||
|
||||
// Clear the flag (in memory and persisted) so the next restart does not purge again.
|
||||
configuration.ResetBots = false;
|
||||
|
||||
@@ -184,14 +184,17 @@ internal sealed class BotGenerator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all bot accounts (and, by cascade, their characters and owned data).
|
||||
/// Deletes all bot accounts with their characters, item storages and items.
|
||||
/// </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;
|
||||
|
||||
// Collect first, delete afterwards: the paging query orders by login name, so deleting while
|
||||
// paging would shift the accounts which are not visited yet into the pages already passed.
|
||||
var loginNames = new List<string>();
|
||||
const int pageSize = 100;
|
||||
var skip = 0;
|
||||
while (true)
|
||||
@@ -203,31 +206,51 @@ internal sealed class BotGenerator
|
||||
break;
|
||||
}
|
||||
|
||||
var bots = page.Where(a => a.IsBot).ToList();
|
||||
var removed = 0;
|
||||
foreach (var bot in bots)
|
||||
loginNames.AddRange(page.Where(account => account.IsBot).Select(account => account.LoginName));
|
||||
skip += page.Count;
|
||||
}
|
||||
|
||||
var deleted = 0;
|
||||
foreach (var loginName in loginNames)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Load the account again, this time with its whole graph: the paging query returns the
|
||||
// accounts untracked and without their characters, and deleting such a shallow account
|
||||
// leaves its item storages behind. A character's inventory is referenced BY the character,
|
||||
// so no delete cascade ever reaches it - those storages, and every item lying in them, would
|
||||
// stay in the database forever as unreachable rows.
|
||||
var account = await context.GetAccountByLoginNameAsync(loginName, cancellationToken).ConfigureAwait(false);
|
||||
if (account is null)
|
||||
{
|
||||
if (await context.DeleteAsync(bot).ConfigureAwait(false))
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var character in account.Characters)
|
||||
{
|
||||
if (character.Inventory is { } inventory)
|
||||
{
|
||||
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);
|
||||
await context.DeleteAsync(inventory).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if (account.Vault is { } vault)
|
||||
{
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await context.DeleteAsync(vault).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
skip += page.Count - removed;
|
||||
if (await context.DeleteAsync(account).ConfigureAwait(false))
|
||||
{
|
||||
deleted++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Not silent: a bot account which survives the purge is spawned again right after it.
|
||||
this._logger.LogWarning("Bot account '{LoginName}' could not be deleted.", loginName);
|
||||
}
|
||||
|
||||
// Save per account, so a single failure does not roll back the accounts already deleted.
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
|
||||
@@ -24,8 +24,49 @@ using MUnique.OpenMU.GameLogic.PlayerActions.Items;
|
||||
/// </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 jewels a bot has a use for: it upgrades its own gear with them (see the policy above).
|
||||
/// Everything else - Chaos, Creation, Guardian, Gemstone, Harmony, the refine stones - is only
|
||||
/// spendable by trading or crafting, which a bot does neither of.
|
||||
/// </summary>
|
||||
internal static readonly ItemIdentifier[] UsableJewels =
|
||||
[
|
||||
ItemConstants.JewelOfBless,
|
||||
ItemConstants.JewelOfSoul,
|
||||
ItemConstants.JewelOfLife,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Jewels a bot may still be carrying from before but can never spend: it neither trades nor crafts.
|
||||
/// They are not picked up anymore, so this is about clearing out what is already in the backpack.
|
||||
/// </summary>
|
||||
internal static readonly ItemIdentifier[] UnusableJewels =
|
||||
[
|
||||
ItemConstants.JewelOfChaos,
|
||||
ItemConstants.JewelOfCreation,
|
||||
ItemConstants.JewelOfGuardian,
|
||||
ItemConstants.Gemstone,
|
||||
ItemConstants.JewelOfHarmony,
|
||||
ItemConstants.LowerRefineStone,
|
||||
ItemConstants.HigherRefineStone,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Jewels of Bless per shopping trip. Each kind has its OWN budget on purpose: with one shared
|
||||
/// budget the Bless rule - which has a target whenever any equipped piece is below +6, and a swapped
|
||||
/// in piece arrives at the level it dropped with - consumed every use, every trip, and the Soul and
|
||||
/// Life rules below were never reached at all.
|
||||
/// </summary>
|
||||
private const int MaxBlessPerTrip = 2;
|
||||
|
||||
/// <summary>Jewels of Soul per shopping trip.</summary>
|
||||
private const int MaxSoulPerTrip = 1;
|
||||
|
||||
/// <summary>Jewels of Life per shopping trip.</summary>
|
||||
private const int MaxLifePerTrip = 1;
|
||||
|
||||
/// <summary>Safety net for the planning loop; the per-kind budgets are the real limit.</summary>
|
||||
private const int MaxUsesPerTrip = MaxBlessPerTrip + MaxSoulPerTrip + MaxLifePerTrip;
|
||||
|
||||
/// <summary>The Jewel of Bless upgrades item levels 0..5 (see <c>BlessJewelConsumeHandlerPlugIn</c>).</summary>
|
||||
private const byte BlessMaxTargetLevel = 5;
|
||||
@@ -56,6 +97,9 @@ internal static class BotJewelHandler
|
||||
/// <summary>Only risk a Life with at least this many in stock.</summary>
|
||||
private const int MinLifeStock = 2;
|
||||
|
||||
/// <summary>Fallback stock per kind when the bot feature has no configuration at hand.</summary>
|
||||
private const int DefaultJewelStockPerKind = 10;
|
||||
|
||||
private static readonly ItemConsumeAction ConsumeAction = new();
|
||||
private static readonly MoveItemAction MoveAction = new();
|
||||
|
||||
@@ -74,8 +118,10 @@ internal static class BotJewelHandler
|
||||
}
|
||||
|
||||
var uses = 0;
|
||||
var lifeUsed = false;
|
||||
while (uses < MaxUsesPerTrip && PlanNextUse(player, lifeUsed) is { } plan)
|
||||
var blessLeft = MaxBlessPerTrip;
|
||||
var soulLeft = MaxSoulPerTrip;
|
||||
var lifeLeft = MaxLifePerTrip;
|
||||
while (uses < MaxUsesPerTrip && PlanNextUse(player, blessLeft, soulLeft, lifeLeft) is { } plan)
|
||||
{
|
||||
if (!await ApplyJewelAsync(player, plan.Jewel, plan.Target).ConfigureAwait(false))
|
||||
{
|
||||
@@ -83,7 +129,18 @@ internal static class BotJewelHandler
|
||||
}
|
||||
|
||||
uses++;
|
||||
lifeUsed |= plan.IsLife;
|
||||
if (IsJewel(plan.Jewel, ItemConstants.JewelOfBless))
|
||||
{
|
||||
blessLeft--;
|
||||
}
|
||||
else if (IsJewel(plan.Jewel, ItemConstants.JewelOfSoul))
|
||||
{
|
||||
soulLeft--;
|
||||
}
|
||||
else
|
||||
{
|
||||
lifeLeft--;
|
||||
}
|
||||
}
|
||||
|
||||
if (uses > 0)
|
||||
@@ -106,8 +163,10 @@ internal static class BotJewelHandler
|
||||
/// <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)
|
||||
/// <param name="blessLeft">How many Jewels of Bless may still be used this trip.</param>
|
||||
/// <param name="soulLeft">How many Jewels of Soul may still be used this trip.</param>
|
||||
/// <param name="lifeLeft">How many Jewels of Life may still be used this trip.</param>
|
||||
internal static (Item Jewel, Item Target)? PlanNextUse(Player player, int blessLeft, int soulLeft, int lifeLeft)
|
||||
{
|
||||
if (player.Inventory is not { } inventory)
|
||||
{
|
||||
@@ -125,17 +184,19 @@ internal static class BotJewelHandler
|
||||
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
|
||||
if (blessLeft > 0
|
||||
&& blessStock.Count > 0
|
||||
&& equipped.Where(i => i.CanLevelBeUpgraded() && i.Level <= BlessMaxTargetLevel)
|
||||
.OrderBy(i => i.Level)
|
||||
.FirstOrDefault() is { } blessTarget)
|
||||
{
|
||||
return (blessStock[0], blessTarget, false);
|
||||
return (blessStock[0], blessTarget);
|
||||
}
|
||||
|
||||
// 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
|
||||
if (soulLeft > 0
|
||||
&& soulStock.Count >= MinSoulStock
|
||||
&& equipped.Where(i => i.CanLevelBeUpgraded()
|
||||
&& i.Level >= SoulMinTargetLevel
|
||||
&& i.Level <= (HasLuck(i) ? SoulMaxTargetLevelLucky : SoulMaxTargetLevelPlain))
|
||||
@@ -143,24 +204,118 @@ internal static class BotJewelHandler
|
||||
.ThenBy(i => i.Level)
|
||||
.FirstOrDefault() is { } soulTarget)
|
||||
{
|
||||
return (soulStock[0], soulTarget, false);
|
||||
return (soulStock[0], soulTarget);
|
||||
}
|
||||
|
||||
// 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
|
||||
if (lifeLeft > 0
|
||||
&& lifeStock.Count >= MinLifeStock
|
||||
&& equipped.Where(i => i.IsWearable() && i.Level >= LifeMinTargetLevel)
|
||||
.OrderByDescending(i => i.Level)
|
||||
.FirstOrDefault() is { } lifeTarget)
|
||||
{
|
||||
return (lifeStock[0], lifeTarget, true);
|
||||
return (lifeStock[0], lifeTarget);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the bot carries jewels it should get rid of at a merchant: any it cannot use at all, or
|
||||
/// more of a usable kind than its stock limit. Deliberately cheap - the trip planner asks this on
|
||||
/// every check, so unlike the full junk scan it must not plan equipment swaps.
|
||||
/// </summary>
|
||||
/// <param name="player">The bot player.</param>
|
||||
/// <returns><c>True</c>, if there is jewel surplus to sell.</returns>
|
||||
internal static bool HasSurplus(Player player)
|
||||
{
|
||||
if (player.Inventory is not { } inventory)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var limit = GetStockLimit(player);
|
||||
var counts = new Dictionary<ItemIdentifier, int>();
|
||||
foreach (var item in inventory.Items)
|
||||
{
|
||||
if (item.ItemSlot < InventoryConstants.EquippableSlotsCount
|
||||
|| item.Definition is not { } definition)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var identifier = new ItemIdentifier(definition.Number, definition.Group);
|
||||
if (UnusableJewels.Contains(identifier))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!UsableJewels.Contains(identifier))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var count = counts.GetValueOrDefault(identifier) + 1;
|
||||
if (count > limit)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
counts[identifier] = count;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the bot has a jewel it could spend on its gear right now. Jewels are only used at the
|
||||
/// end of a merchant trip - the rare, safe, player-like moment for it - so the trip planner asks
|
||||
/// this to decide whether a visit is worth making at all.
|
||||
/// </summary>
|
||||
/// <param name="player">The bot player.</param>
|
||||
/// <returns><c>True</c>, if an upgrade is pending.</returns>
|
||||
internal static bool HasPendingUpgrade(Player player)
|
||||
=> PlanNextUse(player, MaxBlessPerTrip, MaxSoulPerTrip, MaxLifePerTrip) is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the bot still has room in its stock for this jewel - the pickup handler asks before
|
||||
/// taking one from the ground, and the merchant trade asks to tell a working stock from surplus.
|
||||
/// </summary>
|
||||
/// <param name="player">The bot player.</param>
|
||||
/// <param name="item">The jewel to judge.</param>
|
||||
/// <returns><c>True</c>, if it is a usable jewel and the stock is not full yet.</returns>
|
||||
internal static bool WantsMoreOf(Player player, Item item)
|
||||
{
|
||||
if (item.Definition is not { } definition)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var identifier = new ItemIdentifier(definition.Number, definition.Group);
|
||||
return UsableJewels.Contains(identifier)
|
||||
&& CountInStock(player, identifier) < GetStockLimit(player);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured number of jewels a bot keeps of each usable kind.
|
||||
/// </summary>
|
||||
/// <param name="player">The bot player.</param>
|
||||
/// <returns>The stock limit per kind.</returns>
|
||||
internal static int GetStockLimit(Player player)
|
||||
=> BotFeaturePlugIn.GetConfiguration(player.GameContext)?.GetEffectiveJewelStockPerKind()
|
||||
?? DefaultJewelStockPerKind;
|
||||
|
||||
/// <summary>
|
||||
/// Counts how many jewels of the given kind the bot carries.
|
||||
/// </summary>
|
||||
/// <param name="player">The bot player.</param>
|
||||
/// <param name="identifier">The jewel kind.</param>
|
||||
/// <returns>The number of jewels of that kind in the inventory.</returns>
|
||||
internal static int CountInStock(Player player, ItemIdentifier identifier)
|
||||
=> player.Inventory?.Items.Count(i => IsJewel(i, identifier)) ?? 0;
|
||||
|
||||
/// <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
|
||||
|
||||
@@ -29,6 +29,12 @@ public sealed class BotManager
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<BotPlayer> Bots => this._bots.Values.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of currently active bots, without taking a snapshot of them - the periodic task
|
||||
/// asks every second whether there is anything to stop.
|
||||
/// </summary>
|
||||
public int BotCount => this._bots.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Spawns a bot which drives a specific character of the given account.
|
||||
/// </summary>
|
||||
|
||||
@@ -43,6 +43,24 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
/// <summary>Range (tiles) around the origin that counts as "at the hunting ground".</summary>
|
||||
private const int HuntingRange = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Distance (tiles) at which a hunting ground counts as half as attractive as one the bot is standing on.
|
||||
/// See <see cref="GroundWeight"/>.
|
||||
/// </summary>
|
||||
private const int ProximityFalloff = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Width (tiles) of the box a hunting ground point is sampled from when the spawn area itself is smaller.
|
||||
/// See <see cref="TryPickWalkablePoint"/>.
|
||||
/// </summary>
|
||||
private const int GroundScatterSpan = 2 * HuntingRange;
|
||||
|
||||
/// <summary>
|
||||
/// Share of the shopping cooldown the next trip is spread over, so that bots which shopped together
|
||||
/// don't come back together. See <see cref="NextShoppingCheckUtc"/>.
|
||||
/// </summary>
|
||||
private const double ShoppingSpread = 0.4;
|
||||
|
||||
/// <summary>
|
||||
/// How far the bot looks for an actual live monster to home in on. MU spawn areas span most of the map
|
||||
/// (e.g. Lorencia's are ~86x233 / ~105x68 tiles) with only a few dozen monsters each, so their monsters
|
||||
@@ -119,6 +137,9 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
/// </summary>
|
||||
private const int DeathSiteAvoidanceRange = 30;
|
||||
|
||||
/// <summary>The game's own warp action, so a bot travels on exactly a player's terms - fare included.</summary>
|
||||
private static readonly WarpAction WarpAction = new();
|
||||
|
||||
private static readonly TimeSpan EvaluationInterval = TimeSpan.FromSeconds(1);
|
||||
private static readonly TimeSpan EmptyGroundGrace = TimeSpan.FromSeconds(8);
|
||||
|
||||
@@ -134,6 +155,23 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
/// <summary>Minimum time between two cross-map warps, so a bot does not bounce between maps (a real player does not hop maps every minute either).</summary>
|
||||
private static readonly TimeSpan WarpCooldown = TimeSpan.FromMinutes(3);
|
||||
|
||||
/// <summary>
|
||||
/// How long a bot may go without landing a single hit before it treats its map as barren and steps
|
||||
/// down (see <see cref="TryPickEasierMap"/>). Long enough that a walk across a map, a merchant trip
|
||||
/// or a quiet stretch between spawns does not count as one.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan BarrenMapDuration = TimeSpan.FromMinutes(3);
|
||||
|
||||
/// <summary>
|
||||
/// Share of the picks the best ranked map takes when the bot chooses where to hunt (see
|
||||
/// <see cref="PickByRank"/>); the runners-up split what is left, on the same terms. Below a half on
|
||||
/// purpose: at a half the fourth map of a level band and everything behind it share a few percent
|
||||
/// between them, which left Karutan, Icarus and Kanturu with three or four bots each while the top
|
||||
/// map held a hundred. The bot still prefers the best ground - every candidate it draws from is
|
||||
/// already a step up from where it stands - it just does not treat the runner-up as worthless.
|
||||
/// </summary>
|
||||
private const double BestCandidateShare = 0.35;
|
||||
|
||||
/// <summary>Cooldown for following the party leader to another map (shorter, so the group regroups quickly).</summary>
|
||||
private static readonly TimeSpan FollowWarpCooldown = TimeSpan.FromSeconds(20);
|
||||
|
||||
@@ -198,7 +236,14 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
private int _travelPathIndex;
|
||||
private Point _travelPathTarget;
|
||||
private Point? _shoppingTarget;
|
||||
private DateTime _nextShoppingCheckUtc = DateTime.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// When this bot looks at its supplies again. Bots come up with the server, so starting them all at
|
||||
/// "now" sent the whole population to the same merchant in the same minute - and since the cooldown
|
||||
/// was the same for everyone, nothing ever broke that herd apart again. The first check is therefore
|
||||
/// spread over the cooldown, and every following one is spread around it.
|
||||
/// </summary>
|
||||
private DateTime _nextShoppingCheckUtc = DateTime.UtcNow + (ShoppingCooldown * Rand.NextDouble());
|
||||
private DateTime? _resetDueAtUtc;
|
||||
private short _leaderMapNumber;
|
||||
private DateTime _leaderOnMapSinceUtc = DateTime.MinValue;
|
||||
@@ -230,6 +275,24 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
EvaluationInterval);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rates a spawn area as a hunting ground for a bot standing at the given point.
|
||||
/// </summary>
|
||||
/// <param name="area">The spawn area.</param>
|
||||
/// <param name="from">The point the bot measures from.</param>
|
||||
/// <returns>The relative weight of the area.</returns>
|
||||
internal static int GroundWeight(MonsterSpawnArea area, Point from)
|
||||
{
|
||||
// Manhattan distances on a 256x256 map reach ~460, so a linear "300 - distance" term rated a
|
||||
// ground next to the entrance up to 299 while everything past the halfway point clamped to 1.
|
||||
// Bots enter a map through the same gate and therefore all measure from the same spot, so that
|
||||
// ratio made them queue up on the handful of spawns nearest to it. A hyperbolic falloff keeps
|
||||
// the preference for close grounds - travel is still time not spent hunting - without writing
|
||||
// off the rest of the map.
|
||||
var proximity = (ProximityFalloff * ProximityFalloff) / (ProximityFalloff + GroundDistance(area, from));
|
||||
return Math.Max(1, (int)area.Quantity) * Math.Max(1, proximity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask DisposeAsyncCore()
|
||||
{
|
||||
@@ -274,13 +337,18 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
private static int GetMonsterLevel(MonsterDefinition definition)
|
||||
=> (int)(definition.Attributes.FirstOrDefault(a => a.AttributeDefinition == Stats.Level)?.Value ?? 0f);
|
||||
|
||||
private static int GroundWeight(MonsterSpawnArea area, Point from)
|
||||
/// <summary>Manhattan distance between the center of the spawn area and the given point.</summary>
|
||||
/// <summary>
|
||||
/// Returns the time of the next supply check, spread around <see cref="ShoppingCooldown"/> so that bots
|
||||
/// which just shopped together don't queue up at the merchant together again.
|
||||
/// </summary>
|
||||
/// <returns>The time of the next supply check.</returns>
|
||||
private static DateTime NextShoppingCheckUtc()
|
||||
{
|
||||
var proximity = Math.Max(1, 300 - GroundDistance(area, from));
|
||||
return Math.Max(1, (int)area.Quantity) * proximity;
|
||||
var spread = 1 + (((Rand.NextDouble() * 2) - 1) * ShoppingSpread);
|
||||
return DateTime.UtcNow + (ShoppingCooldown * spread);
|
||||
}
|
||||
|
||||
/// <summary>Manhattan distance between the center of the spawn area and the given point.</summary>
|
||||
private static int GroundDistance(MonsterSpawnArea area, Point from)
|
||||
{
|
||||
var centerX = (area.X1 + area.X2) / 2;
|
||||
@@ -564,7 +632,7 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
this._player.Logger.LogInformation("Bot '{Name}' gives up its shopping trip: no route to the merchant at {Target}.", this._player.Name, target);
|
||||
this._shoppingTarget = null;
|
||||
this._player.IsOnShoppingTrip = false;
|
||||
this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCooldown;
|
||||
this._nextShoppingCheckUtc = NextShoppingCheckUtc();
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -579,7 +647,7 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
|
||||
this._shoppingTarget = null;
|
||||
this._player.IsOnShoppingTrip = false;
|
||||
this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCooldown;
|
||||
this._nextShoppingCheckUtc = NextShoppingCheckUtc();
|
||||
this._lastMoveUtc = DateTime.UtcNow; // standing at the shop is not "stuck"
|
||||
return true;
|
||||
}
|
||||
@@ -595,7 +663,7 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
if (BotShoppingHandler.FindMerchantPosition(map) is not { } merchantPosition)
|
||||
if (BotShoppingHandler.FindMerchantPosition(this._player, map) is not { } merchantPosition)
|
||||
{
|
||||
// No merchant lives on this map at all (the Dungeon has no town). Shopping here would
|
||||
// starve the bot's logistics for as long as it stays - no potion restock, no selling, a
|
||||
@@ -815,8 +883,9 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
{
|
||||
ExitGate? warpListGate = null;
|
||||
if (leader.CurrentMap is { } targetMap
|
||||
&& !this.TryGetLegalWarpGate(targetMap.Definition, out warpListGate)
|
||||
&& targetMap.Definition.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number)
|
||||
&& (this.TryGetLegalWarp(targetMap.Definition, out var leaderWarp)
|
||||
? (warpListGate = leaderWarp.Gate) is null
|
||||
: targetMap.Definition.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number))
|
||||
{
|
||||
// The leader moved to a map the bot's plain character level cannot legally enter
|
||||
// (level gates map access, the same rule as everywhere else). Rather than trail
|
||||
@@ -982,36 +1051,76 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
var currentMap = this._player.CurrentMap?.Definition;
|
||||
var mapIsHostile = currentMap is not null && this.BestSafeLevel(currentMap) == 0;
|
||||
var mapIsIllegal = currentMap is not null
|
||||
&& !this.TryGetLegalWarpGate(currentMap, out _)
|
||||
&& !this.TryGetLegalWarp(currentMap, out _)
|
||||
&& currentMap.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number;
|
||||
var mustEscape = mapIsHostile || mapIsIllegal;
|
||||
|
||||
if ((plainLevel < MinWarpLevel && !mustEscape)
|
||||
// A map can pass every check above and still pay the bot nothing: the safe monsters are a rare
|
||||
// kind among ones it must refuse, or stronger bots empty the grounds before it arrives. The bot
|
||||
// notices the way a player would - it has not landed a hit in minutes - and steps DOWN to easier
|
||||
// ground to earn its way back up, instead of walking between hunting grounds forever.
|
||||
var mapIsBarren = DateTime.UtcNow - this._player.LastAttackUtc > BarrenMapDuration;
|
||||
|
||||
if ((plainLevel < MinWarpLevel && !mustEscape && !mapIsBarren)
|
||||
|| DateTime.UtcNow - this._lastWarpUtc < WarpCooldown)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// When escaping, any legal map with something safe to hunt beats staying - and with no legal
|
||||
// warp target at all (a freshly reset veteran may be below every warp requirement), the bot
|
||||
// retreats to its class home town, like a player using a town scroll.
|
||||
if (!this.TryPickBetterMap(mustEscape, out var targetGate, out var targetMap, out var targetLevel)
|
||||
&& !(mustEscape && this.TryGetHomeEscapeGate(out targetGate, out targetMap, out targetLevel)))
|
||||
// When escaping, any legal map with something safe to hunt beats staying. The home retreat is
|
||||
// the last resort AND the safety valve: a bot which cannot afford a single warp - or is below
|
||||
// every warp requirement, as a freshly reset veteran is - would otherwise be stranded on ground
|
||||
// that pays it nothing, unable to earn the fare out of it. Walking home is free, like a player
|
||||
// taking the long way back, and its home map is starter ground it can always hunt.
|
||||
WarpInfo? fare = null;
|
||||
ExitGate targetGate;
|
||||
GameMapDefinition? targetMap;
|
||||
int targetLevel;
|
||||
if ((mapIsBarren && this.TryPickEasierMap(out var easierWarp, out targetMap, out targetLevel))
|
||||
|| this.TryPickBetterMap(mustEscape, out easierWarp, out targetMap, out targetLevel))
|
||||
{
|
||||
fare = easierWarp;
|
||||
targetGate = easierWarp.Gate!;
|
||||
}
|
||||
else if ((mustEscape || mapIsBarren)
|
||||
&& this.TryGetHomeEscapeGate(out var homeGate, out targetMap, out targetLevel))
|
||||
{
|
||||
targetGate = homeGate;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// The barren timer restarts with the move: the new map deserves the same chance to prove itself
|
||||
// before it is judged, and without this every following tick would warp again.
|
||||
this._player.LastAttackUtc = DateTime.UtcNow;
|
||||
this._lastWarpUtc = DateTime.UtcNow;
|
||||
this._hasDestination = false;
|
||||
this._travelPath = null;
|
||||
this._player.Logger.LogInformation(
|
||||
"Bot {Character} (level {Level}) warping to map {Map} (monsters ~{MonsterLevel}).",
|
||||
"Bot {Character} (level {Level}) warping to map {Map} (monsters ~{MonsterLevel}, fare {Fare} zen).",
|
||||
this._player.Name,
|
||||
botLevel,
|
||||
targetMap.Name,
|
||||
targetLevel);
|
||||
await this._player.WarpToAsync(targetGate).ConfigureAwait(false);
|
||||
await this.TryPersistCurrentMapAsync(targetMap).ConfigureAwait(false);
|
||||
targetMap!.Name,
|
||||
targetLevel,
|
||||
fare?.Costs ?? 0);
|
||||
|
||||
if (fare is not null)
|
||||
{
|
||||
// Through the player action, so the bot pays the fare and passes every check the game makes
|
||||
// of a player warping there - including any this navigator does not know about.
|
||||
await WarpAction.WarpToAsync(this._player, fare).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this._player.WarpToAsync(targetGate).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Where the character ACTUALLY ended up, not where it was sent: the warp action re-checks what
|
||||
// the candidate filter checked and may refuse, and remembering a refused trip as a move would
|
||||
// reload the bot onto a map it never reached.
|
||||
await this.TryPersistCurrentMapAsync(this._player.CurrentMap?.Definition ?? targetMap).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1208,8 +1317,10 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// A stack holds as many charges as the item definition allows - no more (see EmergencyPotionCharges).
|
||||
var stackSize = Math.Max((byte)1, definition.Durability);
|
||||
// Only ever up to the emergency amount, never a full stack: a Large Healing Potion holds 255
|
||||
// charges, four times what a bot stocks at a merchant, so handing out whole stacks here made the
|
||||
// fallback the bot's normal potion supply and the shopping trip never had a reason to restock.
|
||||
var stackSize = Math.Min(Math.Max((byte)1, definition.Durability), (byte)EmergencyPotionCharges);
|
||||
foreach (var potion in potions.Where(p => p.Durability < stackSize))
|
||||
{
|
||||
charges += (int)(stackSize - potion.Durability);
|
||||
@@ -1322,7 +1433,7 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
/// <summary>
|
||||
/// The bot's reset-aware effective level (see <see cref="BotResetHandler.GetEffectiveLevel"/>),
|
||||
/// used for hunting decisions (which monsters pay off). Map ACCESS is deliberately not decided
|
||||
/// by this - the game gates warps by the plain character level (see <see cref="TryGetLegalWarpGate"/>),
|
||||
/// by this - the game gates warps by the plain character level (see <see cref="TryGetLegalWarp"/>),
|
||||
/// and the bots must obey the same rule.
|
||||
/// </summary>
|
||||
private int GetBotLevel() => BotResetHandler.GetEffectiveLevel(this._player);
|
||||
@@ -1337,23 +1448,30 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
/// <param name="mapDefinition">The target map.</param>
|
||||
/// <param name="gate">The warp target gate, if a legal entry exists.</param>
|
||||
/// <returns>True, if the bot may warp to the map.</returns>
|
||||
private bool TryGetLegalWarpGate(GameMapDefinition mapDefinition, [MaybeNullWhen(false)] out ExitGate gate)
|
||||
private bool TryGetLegalWarp(GameMapDefinition mapDefinition, [MaybeNullWhen(false)] out WarpInfo warp)
|
||||
{
|
||||
gate = null;
|
||||
warp = null;
|
||||
if (this._player.SelectedCharacter is not { } character)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var plainLevel = (int)(this._player.Attributes?[Stats.Level] ?? 1);
|
||||
gate = this._player.GameContext.Configuration.WarpList
|
||||
warp = this._player.GameContext.Configuration.WarpList
|
||||
.Where(w => w.Gate?.Map?.Number == mapDefinition.Number
|
||||
&& character.GetEffectiveMoveLevelRequirement(w.LevelRequirement) <= plainLevel)
|
||||
.Select(w => w.Gate!)
|
||||
.FirstOrDefault();
|
||||
return gate is not null;
|
||||
return warp is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the bot can pay the warp's fare. A bot travels on the same terms as a player - it pays
|
||||
/// what the warp costs - so a map it cannot afford is not offered to it in the first place, rather
|
||||
/// than chosen and then refused by <see cref="WarpAction"/>.
|
||||
/// </summary>
|
||||
/// <param name="warp">The warp to pay for.</param>
|
||||
private bool CanAffordWarp(WarpInfo warp) => this._player.Money >= warp.Costs;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the escape gate to the bot's class home town, for when it must leave its current map but is
|
||||
/// below every warp requirement (e.g. a freshly reset veteran) - the equivalent of a town scroll.
|
||||
@@ -1446,36 +1564,89 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
return area is { Quantity: > 0, SpawnTrigger: SpawnTrigger.Automatic, MonsterDefinition.ObjectKind: NpcObjectKind.Monster };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the legally warpable map with the strongest monsters the bot can safely fight among those
|
||||
/// EASIER than where it stands now - one step down, not a retreat to the starter map. Used when the
|
||||
/// current map pays nothing (see <see cref="BarrenMapDuration"/>): the bot keeps stepping down every
|
||||
/// barren stretch until it finds ground it can actually farm, and the regular map choice carries it
|
||||
/// back up as its gear and level recover.
|
||||
/// </summary>
|
||||
/// <param name="gate">The warp gate of the chosen map.</param>
|
||||
/// <param name="mapDefinition">The chosen map.</param>
|
||||
/// <param name="monsterLevel">The level of the strongest safe monster there.</param>
|
||||
private bool TryPickEasierMap(out WarpInfo warp, out GameMapDefinition mapDefinition, out int monsterLevel)
|
||||
{
|
||||
warp = default!;
|
||||
mapDefinition = default!;
|
||||
monsterLevel = 0;
|
||||
|
||||
var current = this._player.CurrentMap?.Definition;
|
||||
|
||||
// With nothing safe here at all, every huntable map is a step down - the comparison must not
|
||||
// rule them all out.
|
||||
var currentLevel = current is null ? int.MaxValue : this.BestSafeLevel(current);
|
||||
if (currentLevel <= 0)
|
||||
{
|
||||
currentLevel = int.MaxValue;
|
||||
}
|
||||
|
||||
foreach (var candidate in this._player.GameContext.Configuration.Maps)
|
||||
{
|
||||
if (ReferenceEquals(candidate, current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var best = this.BestSafeLevel(candidate);
|
||||
if (best <= 0 || best >= currentLevel || best <= monsterLevel)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!candidate.TryGetRequirementError(this._player, out _)
|
||||
&& this.TryGetLegalWarp(candidate, out var candidateWarp)
|
||||
&& this.CanAffordWarp(candidateWarp))
|
||||
{
|
||||
warp = candidateWarp;
|
||||
mapDefinition = candidate;
|
||||
monsterLevel = best;
|
||||
}
|
||||
}
|
||||
|
||||
return mapDefinition is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the legally warpable map (other than the current one) that offers the strongest monsters the
|
||||
/// bot can still safely handle, if it is meaningfully better than the current map, with its warp gate.
|
||||
/// In escape mode (fleeing a hostile or illegal map) any legal map with something safe to hunt counts,
|
||||
/// no matter how it compares to the current one.
|
||||
/// </summary>
|
||||
private bool TryPickBetterMap(bool escape, out ExitGate gate, out GameMapDefinition mapDefinition, out int monsterLevel)
|
||||
private bool TryPickBetterMap(bool escape, out WarpInfo warp, out GameMapDefinition mapDefinition, out int monsterLevel)
|
||||
{
|
||||
// A mastered bot earns nothing below the master-experience floor, so it first looks for a map
|
||||
// which pays at all. Only when no map within its reach offers monsters above the floor that it
|
||||
// can safely fight does it fall back to hunting by safety alone: standing on a map it cannot
|
||||
// survive would not earn it master experience either, and its gear still improves meanwhile.
|
||||
var floor = this.MasterExperienceFloor();
|
||||
return (floor > 0 && this.TryPickBetterMapCore(escape, floor, out gate, out mapDefinition, out monsterLevel))
|
||||
|| this.TryPickBetterMapCore(escape, 0, out gate, out mapDefinition, out monsterLevel);
|
||||
return (floor > 0 && this.TryPickBetterMapCore(escape, floor, out warp, out mapDefinition, out monsterLevel))
|
||||
|| this.TryPickBetterMapCore(escape, 0, out warp, out mapDefinition, out monsterLevel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the best map (see <see cref="TryPickBetterMap"/>), counting only monsters of at least
|
||||
/// <paramref name="minimumMonsterLevel"/>.
|
||||
/// </summary>
|
||||
private bool TryPickBetterMapCore(bool escape, int minimumMonsterLevel, out ExitGate gate, out GameMapDefinition mapDefinition, out int monsterLevel)
|
||||
private bool TryPickBetterMapCore(bool escape, int minimumMonsterLevel, out WarpInfo warp, out GameMapDefinition mapDefinition, out int monsterLevel)
|
||||
{
|
||||
gate = default!;
|
||||
warp = default!;
|
||||
mapDefinition = default!;
|
||||
monsterLevel = 0;
|
||||
|
||||
var current = this._player.CurrentMap?.Definition;
|
||||
var threshold = escape ? 1 : (current is null ? 0 : this.BestSafeLevel(current, minimumMonsterLevel)) + WarpImprovementMargin;
|
||||
|
||||
var candidates = new List<(WarpInfo Warp, GameMapDefinition Map, int Level)>();
|
||||
foreach (var candidate in this._player.GameContext.Configuration.Maps)
|
||||
{
|
||||
if (ReferenceEquals(candidate, current))
|
||||
@@ -1489,16 +1660,48 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.TryGetLegalWarpGate(candidate, out var warpGate))
|
||||
if (this.TryGetLegalWarp(candidate, out var candidateWarp) && this.CanAffordWarp(candidateWarp))
|
||||
{
|
||||
gate = warpGate;
|
||||
mapDefinition = candidate;
|
||||
monsterLevel = best;
|
||||
threshold = best + 1; // keep only a strictly-stronger map after this one
|
||||
candidates.Add((candidateWarp, candidate, best));
|
||||
}
|
||||
}
|
||||
|
||||
return mapDefinition is not null;
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Always taking the single strongest map emptied the world: the maps of a level band differ by a
|
||||
// few monster levels, so one of them is every bot's answer and the rest stand deserted - hundreds
|
||||
// of bots on Vulcanus while Tarkan, Karutan and Kanturu, which their level opens, see nobody. The
|
||||
// strongest map still wins most of the picks (see BestCandidateShare); the runners-up share the
|
||||
// rest, so the population spreads over the maps a player of that band would choose between.
|
||||
var ranked = candidates.OrderByDescending(c => c.Level).ToList();
|
||||
var picked = ranked[PickByRank(ranked.Count)];
|
||||
warp = picked.Warp;
|
||||
mapDefinition = picked.Map;
|
||||
monsterLevel = picked.Level;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks an index into a list ranked best-first, each rank taking
|
||||
/// <see cref="BestCandidateShare"/> of what the ranks before it left over. Deliberately not a
|
||||
/// uniform draw - a bot should prefer the best ground it can hunt, just not to the exclusion of
|
||||
/// everything else.
|
||||
/// </summary>
|
||||
/// <param name="count">The number of ranked candidates.</param>
|
||||
private static int PickByRank(int count)
|
||||
{
|
||||
for (var rank = 0; rank < count - 1; rank++)
|
||||
{
|
||||
if (Rand.NextDouble() < BestCandidateShare)
|
||||
{
|
||||
return rank;
|
||||
}
|
||||
}
|
||||
|
||||
return count - 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1580,15 +1783,27 @@ internal sealed class BotNavigator : AsyncDisposable
|
||||
|
||||
private bool TryPickWalkablePoint(GameMap map, MonsterSpawnArea area, Point? avoidCenter, out Point point)
|
||||
{
|
||||
var minX = Math.Min(area.X1, area.X2);
|
||||
var maxX = Math.Max(area.X1, area.X2);
|
||||
var minY = Math.Min(area.Y1, area.Y2);
|
||||
var maxY = Math.Max(area.Y1, area.Y2);
|
||||
int minX = Math.Min(area.X1, area.X2);
|
||||
int maxX = Math.Max(area.X1, area.X2);
|
||||
int minY = Math.Min(area.Y1, area.Y2);
|
||||
int maxY = Math.Max(area.Y1, area.Y2);
|
||||
|
||||
// Not every map states its spawns as rectangles: LaCleon, for one, lists every spawn as a single
|
||||
// tile, and so do 44 of the 55 maps which hold more than ten of them. Sampling "inside" such an
|
||||
// area can only ever return that one tile, so every bot which picks it walks onto the very same
|
||||
// spot. Scatter around it instead - the bot still arrives within combat range of the monster.
|
||||
var isSingleSpot = (maxX - minX) < GroundScatterSpan && (maxY - minY) < GroundScatterSpan;
|
||||
var center = new Point((byte)((minX + maxX) / 2), (byte)((minY + maxY) / 2));
|
||||
|
||||
for (var attempt = 0; attempt < MaxPointPickAttempts; attempt++)
|
||||
{
|
||||
var x = Rand.NextInt(minX, maxX + 1);
|
||||
var y = Rand.NextInt(minY, maxY + 1);
|
||||
// GetRandomCoordinate already keeps to the map, retries unwalkable picks and falls back to the
|
||||
// point itself when the surroundings offer nothing - so a spawn on a ledge stays usable.
|
||||
var candidate = isSingleSpot
|
||||
? map.Terrain.GetRandomCoordinate(center, HuntingRange)
|
||||
: new Point((byte)Rand.NextInt(minX, maxX + 1), (byte)Rand.NextInt(minY, maxY + 1));
|
||||
int x = candidate.X;
|
||||
int y = candidate.Y;
|
||||
if (!map.Terrain.WalkMap[x, y] || map.Terrain.SafezoneMap[x, y])
|
||||
{
|
||||
continue;
|
||||
|
||||
@@ -67,6 +67,22 @@ internal static class BotProgression
|
||||
/// </summary>
|
||||
private static readonly short[] ExcludedBuffSkillNumbers = [18, 219, 221, 222];
|
||||
|
||||
/// <summary>
|
||||
/// The skills which the game only activates on the castle siege map. Nothing in the skill data marks
|
||||
/// them, but the client does not let a player cast them anywhere else, so a bot hunting with one is a
|
||||
/// bot doing something no player can do. They are also the strongest numbers each class has - they are
|
||||
/// meant to be - which is exactly why a "pick the strongest" rule walks straight into them: a Dark
|
||||
/// Knight fought with Crescent Moon Slash, every elf with Starfall and every Rage Fighter with Charge.
|
||||
/// (44 Crescent Moon Slash, 45 Lance, 46 Starfall, 57 Spiral Slash, 73 Mana Rays, 74 Fire Blast,
|
||||
/// 269 Charge - the same set the client refuses outside a siege.)
|
||||
/// The second group are the skills of the siege roles - the guild's battle masters and its master -
|
||||
/// which are handed out for the siege and are not attacks at all: 67 Stun, 68 Cancel Stun,
|
||||
/// 69 Swell Mana, 70 Invisibility, 71 Cancel Invisibility, 72 Abolish Magic. Stun in particular is
|
||||
/// indistinguishable from a real attack skill by its data alone: like Twisting Slash and Power Slash
|
||||
/// it is an area skill with no damage of its own and a single hit.
|
||||
/// </summary>
|
||||
private static readonly short[] CastleSiegeOnlySkillNumbers = [44, 45, 46, 57, 67, 68, 69, 70, 71, 72, 73, 74, 269];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the class the character evolves into at <see cref="ClassEvolutionLevel"/>, or null when the
|
||||
/// class has no (in-scope) evolution.
|
||||
@@ -294,13 +310,21 @@ internal static class BotProgression
|
||||
return false;
|
||||
}
|
||||
|
||||
if (skill.AttackDamage > 0
|
||||
&& skill.SkillType is SkillType.DirectHit
|
||||
or SkillType.AreaSkillAutomaticHits
|
||||
or SkillType.AreaSkillExplicitHits
|
||||
or SkillType.AreaSkillExplicitTarget)
|
||||
if (CastleSiegeOnlySkillNumbers.Contains(skill.Number))
|
||||
{
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsAttackSkill(skill))
|
||||
{
|
||||
// Worth learning if it adds damage of its own, hits more than once, or hits more than one
|
||||
// target. Judging by AttackDamage alone locked a Rage Fighter out of its entire arsenal:
|
||||
// Killing Blow, Chain Drive, Dragon Roar and Phoenix Shot all carry a flat bonus of zero and
|
||||
// four hits instead, because their damage comes from the weapon - which is also how the
|
||||
// server pays them out.
|
||||
return skill.AttackDamage > 0
|
||||
|| skill.NumberOfHitsPerAttack > 1
|
||||
|| IsAreaSkill(skill);
|
||||
}
|
||||
|
||||
return skill.SkillType is SkillType.Buff or SkillType.Regeneration
|
||||
@@ -308,6 +332,45 @@ internal static class BotProgression
|
||||
&& !ExcludedBuffSkillNumbers.Contains(skill.Number);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the skill deals damage to a target, as opposed to buffing, summoning or the like.
|
||||
/// </summary>
|
||||
/// <param name="skill">The skill.</param>
|
||||
public static bool IsAttackSkill(Skill skill)
|
||||
=> skill.SkillType is SkillType.DirectHit
|
||||
or SkillType.AreaSkillAutomaticHits
|
||||
or SkillType.AreaSkillExplicitHits
|
||||
or SkillType.AreaSkillExplicitTarget;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the skill hits more than its primary target.
|
||||
/// </summary>
|
||||
/// <param name="skill">The skill.</param>
|
||||
public static bool IsAreaSkill(Skill skill)
|
||||
=> skill.SkillType is SkillType.AreaSkillAutomaticHits
|
||||
or SkillType.AreaSkillExplicitHits
|
||||
or SkillType.AreaSkillExplicitTarget;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the skill is one the game only activates during a castle siege, which a bot
|
||||
/// therefore never uses while hunting - not even when it already knows it, as a Dark Knight does:
|
||||
/// Crescent Moon Slash is handed to every one of them when the character is created.
|
||||
/// </summary>
|
||||
/// <param name="skill">The skill.</param>
|
||||
public static bool IsCastleSiegeOnly(Skill skill) => CastleSiegeOnlySkillNumbers.Contains(skill.Number);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the skill belongs to a PET rather than to the character, and may therefore
|
||||
/// only be used while that pet is actually equipped. Plasma Storm is the Fenrir's, and nothing in
|
||||
/// the skill's own numbers gives the missing pet away: the attribute behind its damage
|
||||
/// (<see cref="Attributes.Stats.FenrirBaseDmg"/>) is derived from the character's own strength,
|
||||
/// agility, vitality and energy, so it is large for any high level character - with or without the
|
||||
/// pet. Scoring it by that attribute alone handed Plasma Storm, the longest ranged skill most
|
||||
/// classes own, to a whole population riding nothing.
|
||||
/// </summary>
|
||||
/// <param name="skill">The skill.</param>
|
||||
public static bool RequiresPet(Skill skill) => skill.DamageType == DamageType.Fenrir;
|
||||
|
||||
/// <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).
|
||||
|
||||
@@ -46,10 +46,11 @@ internal sealed class BotServerPartition
|
||||
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.
|
||||
/// Gets a value indicating whether this server generates the bot population - and, likewise, carries
|
||||
/// out the requested reset and purge. Exactly one server does it (the first of the deployment), 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; }
|
||||
|
||||
@@ -120,18 +121,27 @@ internal sealed class BotServerPartition
|
||||
byte serverId,
|
||||
int requestedAccounts)
|
||||
{
|
||||
var servers = capacities.Where(c => c.Capacity > 0).ToList();
|
||||
var allServers = capacities.ToList();
|
||||
var servers = allServers.Where(c => c.Capacity > 0).ToList();
|
||||
var totalCapacity = servers.Sum(server => (long)server.Capacity);
|
||||
|
||||
// Who generates - and purges - the population is decided by the SET of servers alone, never by
|
||||
// how many accounts are configured: a deployment which currently wants zero bots still needs an
|
||||
// owner for the destructive operations, otherwise "delete all bots" would silently do nothing on
|
||||
// every server. The first server (they all walk the list in the same order) takes the role.
|
||||
var owner = servers.Count > 0 ? servers[0].ServerId : allServers.Select(server => (byte?)server.ServerId).FirstOrDefault();
|
||||
var isGenerator = owner == serverId;
|
||||
|
||||
if (totalCapacity == 0 || requestedAccounts <= 0)
|
||||
{
|
||||
return (new BotServerPartition(1, 0, false), 0);
|
||||
return (new BotServerPartition(1, 0, isGenerator), 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);
|
||||
var partition = new BotServerPartition(1, 0, isGenerator);
|
||||
long capacitySoFar = 0;
|
||||
var accountsSoFar = 0;
|
||||
foreach (var (currentServer, capacity) in servers)
|
||||
@@ -144,8 +154,7 @@ internal sealed class BotServerPartition
|
||||
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);
|
||||
partition = new BotServerPartition(accountsSoFar + 1, share, isGenerator);
|
||||
}
|
||||
|
||||
accountsSoFar = accountsUpToHere;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
namespace MUnique.OpenMU.GameLogic.Bots;
|
||||
|
||||
using MUnique.OpenMU.DataModel.Entities;
|
||||
using MUnique.OpenMU.GameLogic.NPC;
|
||||
using MUnique.OpenMU.GameLogic.Offline;
|
||||
using MUnique.OpenMU.GameLogic.PlayerActions;
|
||||
@@ -12,51 +13,48 @@ 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".
|
||||
/// run low, the bot visits a merchant, sells its junk loot for Zen, repairs its gear and buys 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"/>, <see cref="ItemRepairAction"/>), 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>Go restocking once a potion kind holds less than this share of the target stock.</summary>
|
||||
private const int PotionLowThresholdPercent = 66;
|
||||
|
||||
/// <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>
|
||||
/// <summary>Maximum purchases per potion kind per trip - a safety bound, the stock target is the real limit.</summary>
|
||||
private const int MaxPurchasesPerKind = 20;
|
||||
|
||||
/// <summary>Maximum jewels bought per trip: a player buys one now and then, not a hoard at once.</summary>
|
||||
private const int MaxJewelPurchasesPerTrip = 3;
|
||||
|
||||
/// <summary>Zen the bot keeps in reserve - it stops buying rather than spend its last coin.</summary>
|
||||
private const int MinZenReserve = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Zen below which a trip for potions alone is pointless - the cheapest healing item a stock
|
||||
/// merchant sells still costs more than this, so the bot would walk there and buy nothing.
|
||||
/// </summary>
|
||||
private const int MinPotionMoney = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Zen a bot keeps back before it spends anything on jewels. Jewels are a luxury next to potions and
|
||||
/// repairs, which keep the bot alive and fighting, so it only buys them out of real surplus.
|
||||
/// </summary>
|
||||
private const int JewelPurchaseReserve = 10_000_000;
|
||||
|
||||
private static readonly TalkNpcAction TalkAction = new();
|
||||
private static readonly SellItemToNpcAction SellAction = new();
|
||||
private static readonly BuyNpcItemAction BuyAction = new();
|
||||
private static readonly ItemRepairAction RepairAction = 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,
|
||||
];
|
||||
private static readonly ItemPriceCalculator PriceCalculator = new();
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the bot should go shopping: the backpack is filling up with sellable junk,
|
||||
@@ -70,41 +68,65 @@ internal static class BotShoppingHandler
|
||||
return false;
|
||||
}
|
||||
|
||||
var freeSlots = inventory.FreeSlots.Count();
|
||||
if (freeSlots < FreeSlotPressure && inventory.Items.Any(i => IsSellableJunk(player, i)))
|
||||
if (IsUnderSlotPressure(inventory) && GetSellableJunk(player, inventory).Count > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (player.Money > 5000 && GetLowPotionKinds(player).Any())
|
||||
// A potion trip needs something to pay with: Zen, or loot to turn into Zen once it is there.
|
||||
// A broke bot buys nothing, so the trigger still stands when it gets back - and it sets off
|
||||
// again, and again, and never hunts, which is the only way it could have earned the money.
|
||||
// The emergency refill (see BotNavigator) keeps it alive meanwhile, at a stock deliberately
|
||||
// below this target - which is exactly what made the loop permanent rather than occasional.
|
||||
if (GetLowPotionKinds(player).Any()
|
||||
&& (player.Money >= MinPotionMoney || GetSellableJunk(player, inventory).Count > 0))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
// Both remaining triggers exist because a merchant trip is the ONLY moment a bot can turn its
|
||||
// loot into anything: selling and jewel spending both happen there. A filling backpack alone is
|
||||
// not enough of a trigger - it is exactly what the rest of this class stops happening, so a
|
||||
// tidy bot would sit on a hoard it can neither sell nor spend, forever.
|
||||
return BotJewelHandler.HasSurplus(player)
|
||||
|| BotJewelHandler.HasPendingUpgrade(player);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Finds the position of a merchant NPC on the map, preferring the one which sells what the bot
|
||||
/// needs right now. 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="player">The bot player, whose current needs rank the merchants.</param>
|
||||
/// <param name="map">The game map.</param>
|
||||
public static Point? FindMerchantPosition(GameMap map)
|
||||
public static Point? FindMerchantPosition(OfflinePlayer player, 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();
|
||||
|
||||
// A map can have a merchant and still be useless for what the bot came for: Crywolf has a
|
||||
// blacksmith and a wandering merchant, neither of which stocks a single potion. Reporting "none
|
||||
// here" sends the bot home to a real town instead of walking it to a shop that cannot help it,
|
||||
// every cooldown, forever.
|
||||
if (GetLowPotionKinds(player).Any()
|
||||
&& !merchants.Any(m => SellsPotions(m.Definition.MerchantStore!)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var best = merchants
|
||||
.OrderByDescending(m => ScoreMerchant(player, m.Definition.MerchantStore!))
|
||||
.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.
|
||||
/// sells the junk loot, repairs the gear, buys refills and closes the dialog again.
|
||||
/// </summary>
|
||||
/// <param name="player">The bot player.</param>
|
||||
/// <param name="map">The game map.</param>
|
||||
@@ -131,46 +153,35 @@ internal static class BotShoppingHandler
|
||||
|
||||
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++;
|
||||
}
|
||||
// Repairing first is not cosmetic: it is the one purchase the bot always makes, and for a
|
||||
// bot sitting at the money limit the Zen it spends is the only headroom its sales will
|
||||
// have. Selling first meant every sale was refused and the junk was destroyed instead,
|
||||
// while the repair a moment later freed enough room to have sold a good part of it.
|
||||
var repaired = await RepairGearAsync(player).ConfigureAwait(false);
|
||||
var (sold, unsold) = await SellJunkAsync(player, inventory).ConfigureAwait(false);
|
||||
|
||||
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;
|
||||
}
|
||||
var boughtPotions = store is null ? 0 : await BuyPotionsAsync(player, store).ConfigureAwait(false);
|
||||
var boughtJewels = store is null ? 0 : await BuyJewelsAsync(player, store).ConfigureAwait(false);
|
||||
|
||||
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++;
|
||||
}
|
||||
}
|
||||
// Only now, with every purchase of this visit paid for, is it settled how much room the
|
||||
// money limit really leaves: each Zen spent above buys back the chance to sell one more
|
||||
// piece instead of destroying it.
|
||||
var (soldLate, discarded) = await ClearUnsoldAsync(player, inventory, unsold).ConfigureAwait(false);
|
||||
sold += soldLate;
|
||||
|
||||
// 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.
|
||||
// do" from a silently failed trip. Every counter reports what REALLY happened - a sale
|
||||
// which the money limit refused is not a sale.
|
||||
player.Logger.LogInformation(
|
||||
"Bot '{Name}' traded with '{Merchant}': sold {Sold} item(s), bought {Bought} potion stack(s), {Money} zen left.",
|
||||
"Bot '{Name}' traded with '{Merchant}': sold {Sold} item(s), discarded {Discarded}, repaired {Repaired}, bought {Potions} potion stack(s) and {Jewels} jewel(s), {Money} zen left.",
|
||||
player.Name,
|
||||
merchant.Definition.Designation,
|
||||
soldCount,
|
||||
boughtCount,
|
||||
sold,
|
||||
discarded,
|
||||
repaired,
|
||||
boughtPotions,
|
||||
boughtJewels,
|
||||
player.Money);
|
||||
}
|
||||
finally
|
||||
@@ -182,64 +193,363 @@ internal static class BotShoppingHandler
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the potion kinds (item numbers in group 14) whose stack is running low.
|
||||
/// Collects the backpack items the bot has no use for. Not its potions, not the jewels within its
|
||||
/// working stock, and not a piece it would put on - but everything else goes, treasures included.
|
||||
/// Unlike a player, a bot cannot trade an excellent piece away, so hoarding one only silts up the
|
||||
/// backpack until the loot pickup stops entirely.
|
||||
/// </summary>
|
||||
private static IEnumerable<byte> GetLowPotionKinds(Player player)
|
||||
private static List<Item> GetSellableJunk(OfflinePlayer player, IStorage inventory)
|
||||
{
|
||||
if (GetPotionCharges(player, 3) < PotionLowThreshold)
|
||||
var stockLimit = BotJewelHandler.GetStockLimit(player);
|
||||
var keptJewels = new Dictionary<ItemIdentifier, int>();
|
||||
var junk = new List<Item>();
|
||||
|
||||
foreach (var item in inventory.Items)
|
||||
{
|
||||
yield return 3; // Large Healing Potion
|
||||
if (item.ItemSlot < InventoryConstants.EquippableSlotsCount
|
||||
|| item.Definition is not { } definition
|
||||
|| definition.IsAmmunition)
|
||||
{
|
||||
continue; // equipped, or an archer's arrows - selling those would disarm the bow.
|
||||
}
|
||||
|
||||
var identifier = new ItemIdentifier(definition.Number, definition.Group);
|
||||
if (HealingHandler.HealthPotionPriority.Contains(identifier)
|
||||
|| HealingHandler.ManaPotionPriority.Contains(identifier))
|
||||
{
|
||||
continue; // the survival kit the offline AI drinks from.
|
||||
}
|
||||
|
||||
if (BotJewelHandler.UsableJewels.Contains(identifier))
|
||||
{
|
||||
// Keep the working stock and sell the surplus, counting per kind while walking the
|
||||
// backpack: asking "is the stock full?" for each jewel on its own would answer yes for
|
||||
// every one of fifteen Souls at a limit of ten, and the bot would end up with none.
|
||||
var kept = keptJewels.GetValueOrDefault(identifier);
|
||||
if (kept < stockLimit)
|
||||
{
|
||||
keptJewels[identifier] = kept + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
junk.Add(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Whatever the bot would wear stays: selling a piece it picked up as an upgrade one tick
|
||||
// before it puts it on is pure loss.
|
||||
if (!BotEquipmentHandler.IsUpgradeFor(player, item))
|
||||
{
|
||||
junk.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (GetPotionCharges(player, 6) < PotionLowThreshold)
|
||||
return junk;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sells the junk, most valuable piece first, so that a bot which is close to the money limit still
|
||||
/// captures as much of its loot as fits. What the limit refuses is handed back to the caller: it is
|
||||
/// offered once more after the purchases of this visit have freed some room.
|
||||
/// </summary>
|
||||
private static async ValueTask<(int Sold, List<Item> Unsold)> SellJunkAsync(OfflinePlayer player, IStorage inventory)
|
||||
{
|
||||
var junk = GetSellableJunk(player, inventory)
|
||||
.Select(i => (Item: i, Price: PriceCalculator.CalculateSellingPrice(i, i.Durability())))
|
||||
.OrderByDescending(x => x.Price)
|
||||
.ToList();
|
||||
|
||||
var sold = 0;
|
||||
var unsold = new List<Item>();
|
||||
foreach (var (item, _) in junk)
|
||||
{
|
||||
yield return 6; // Large Mana Potion
|
||||
if (await SellAction.SellItemAsync(player, item.ItemSlot).ConfigureAwait(false))
|
||||
{
|
||||
sold++;
|
||||
}
|
||||
else
|
||||
{
|
||||
unsold.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return (sold, unsold);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deals with what the money limit refused earlier. The purchases in between have spent Zen, so a
|
||||
/// second attempt sells whatever now fits. Only what is still refused is destroyed - there is no
|
||||
/// other way out of the backpack for it (a bot cannot trade, and dropping upgraded or excellent gear
|
||||
/// is something no player can do either), and a wedged bot is worse.
|
||||
/// Gear is only destroyed under slot pressure: a full wallet alone is no reason to burn loot, and it
|
||||
/// may well be sellable again next visit. Jewel surplus is not given that benefit of the doubt - a
|
||||
/// kind the bot cannot use at all, or the part above its stock limit, has no use to it whatsoever,
|
||||
/// and waiting for slot pressure just parks it in the backpack for hours.
|
||||
/// </summary>
|
||||
private static async ValueTask<(int Sold, int Discarded)> ClearUnsoldAsync(OfflinePlayer player, IStorage inventory, List<Item> unsold)
|
||||
{
|
||||
if (unsold.Count == 0)
|
||||
{
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
var maximumMoney = player.GameContext.Configuration.MaximumInventoryMoney;
|
||||
var underPressure = IsUnderSlotPressure(inventory);
|
||||
var sold = 0;
|
||||
var discarded = 0;
|
||||
foreach (var item in unsold)
|
||||
{
|
||||
if (await SellAction.SellItemAsync(player, item.ItemSlot).ConfigureAwait(false))
|
||||
{
|
||||
sold++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (underPressure || IsDeadWeight(item))
|
||||
{
|
||||
await player.DestroyInventoryItemAsync(item).ConfigureAwait(false);
|
||||
discarded++;
|
||||
}
|
||||
}
|
||||
|
||||
if (discarded > 0)
|
||||
{
|
||||
player.Logger.LogInformation(
|
||||
"Bot '{Name}' destroyed {Count} item(s) it could not sell: its money is at the maximum of {Maximum}.",
|
||||
player.Name,
|
||||
discarded,
|
||||
maximumMoney);
|
||||
}
|
||||
|
||||
return (sold, discarded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repairs the equipped gear while the merchant dialog is open, which is what earns the NPC discount
|
||||
/// (see <see cref="ItemPriceCalculator.CalculateRepairPrice"/>). Repairing in the field without a
|
||||
/// dialog - what the MU Helper's own auto repair does, and why it stays off for bots - pays the full
|
||||
/// price instead. The cost scales with the missing durability, so repairing on every visit costs
|
||||
/// about the same as one big repair later, and never lets an item reach zero, where the price is
|
||||
/// multiplied by the destroyed-item penalty on top.
|
||||
/// </summary>
|
||||
/// <returns>The number of items which were repaired.</returns>
|
||||
private static async ValueTask<int> RepairGearAsync(OfflinePlayer player)
|
||||
{
|
||||
if (player.Inventory is not { } inventory)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var damaged = new List<(byte Slot, long Price)>();
|
||||
for (var slot = InventoryConstants.FirstEquippableItemSlotIndex; slot <= InventoryConstants.LastEquippableItemSlotIndex; slot++)
|
||||
{
|
||||
if (slot == InventoryConstants.PetSlot)
|
||||
{
|
||||
continue; // pets are repaired by the pet trainer, not here.
|
||||
}
|
||||
|
||||
if (inventory.GetItem(slot) is { } item
|
||||
&& item.Durability() < item.GetMaximumDurabilityOfOnePiece())
|
||||
{
|
||||
damaged.Add((slot, PriceCalculator.CalculateRepairPrice(item, true)));
|
||||
}
|
||||
}
|
||||
|
||||
// Cheapest first, and never spend down to nothing: repairing everything a bot owns can cost more
|
||||
// than it has, and `RepairAllItemsAsync` would take the money for the first pieces and stop -
|
||||
// leaving the rest at zero durability AND the bot too poor to shop again, which is a trap it
|
||||
// cannot get out of, because the merchant trip is the only place its gear ever gets repaired.
|
||||
var repaired = 0;
|
||||
foreach (var (slot, price) in damaged.OrderBy(d => d.Price))
|
||||
{
|
||||
if (player.Money - price < MinZenReserve)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await RepairAction.RepairItemAsync(player, slot).ConfigureAwait(false);
|
||||
if (inventory.GetItem(slot) is { } item
|
||||
&& item.Durability() >= item.GetMaximumDurabilityOfOnePiece())
|
||||
{
|
||||
repaired++;
|
||||
}
|
||||
}
|
||||
|
||||
return repaired;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restocks the potions the offline AI actually drinks, buying the biggest stack the bot can afford
|
||||
/// of the best kind the merchant offers. Merchants often carry the same potion as a small and a large
|
||||
/// stack; taking the first match would buy the tiny one twenty times over.
|
||||
/// </summary>
|
||||
private static async ValueTask<int> BuyPotionsAsync(OfflinePlayer player, ItemStorage store)
|
||||
{
|
||||
var target = GetPotionStockTarget(player);
|
||||
var bought = 0;
|
||||
foreach (var priority in GetLowPotionKinds(player))
|
||||
{
|
||||
for (var i = 0; i < MaxPurchasesPerKind && GetCharges(player, priority) < target; i++)
|
||||
{
|
||||
if (FindBestOffer(player, store, priority) is not { } offer)
|
||||
{
|
||||
break; // the merchant has none of this kind, or none the bot can afford.
|
||||
}
|
||||
|
||||
var moneyBefore = player.Money;
|
||||
await BuyAction.BuyItemAsync(player, offer.ItemSlot).ConfigureAwait(false);
|
||||
if (player.Money >= moneyBefore)
|
||||
{
|
||||
break; // purchase failed (no money / no space)
|
||||
}
|
||||
|
||||
bought++;
|
||||
}
|
||||
}
|
||||
|
||||
return bought;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Buys the jewels the bot can spend on its own gear, if the merchant happens to sell any. No stock
|
||||
/// merchant does, so on a default configuration this simply never fires - it is here for servers
|
||||
/// which put jewels into their shops, where it turns a bot's Zen into actual progress instead of
|
||||
/// letting it pile up against the money limit.
|
||||
/// </summary>
|
||||
private static async ValueTask<int> BuyJewelsAsync(OfflinePlayer player, ItemStorage store)
|
||||
{
|
||||
var bought = 0;
|
||||
var stockLimit = BotJewelHandler.GetStockLimit(player);
|
||||
foreach (var identifier in BotJewelHandler.UsableJewels)
|
||||
{
|
||||
while (bought < MaxJewelPurchasesPerTrip
|
||||
&& BotJewelHandler.CountInStock(player, identifier) < stockLimit
|
||||
&& FindAffordableOffer(player, store, identifier, JewelPurchaseReserve) is { } offer)
|
||||
{
|
||||
var moneyBefore = player.Money;
|
||||
await BuyAction.BuyItemAsync(player, offer.ItemSlot).ConfigureAwait(false);
|
||||
if (player.Money >= moneyBefore)
|
||||
{
|
||||
break; // purchase failed (no money / no space)
|
||||
}
|
||||
|
||||
bought++;
|
||||
}
|
||||
}
|
||||
|
||||
return bought;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the potion kinds whose stock is running low, as the priority lists the offline AI drinks by.
|
||||
/// The charges are counted over the whole list, not per item number: a bot with a full stack of
|
||||
/// medium healing potions is not out of healing just because it holds no large ones.
|
||||
/// </summary>
|
||||
private static IEnumerable<ItemIdentifier[]> GetLowPotionKinds(Player player)
|
||||
{
|
||||
var low = GetPotionStockTarget(player) * PotionLowThresholdPercent / 100;
|
||||
if (GetCharges(player, HealingHandler.HealthPotionPriority) < low)
|
||||
{
|
||||
yield return HealingHandler.HealthPotionPriority;
|
||||
}
|
||||
|
||||
if (GetCharges(player, HealingHandler.ManaPotionPriority) < low)
|
||||
{
|
||||
yield return HealingHandler.ManaPotionPriority;
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetPotionCharges(Player player, byte potionNumber)
|
||||
private static int GetPotionStockTarget(Player player)
|
||||
=> BotFeaturePlugIn.GetConfiguration(player.GameContext)?.GetEffectivePotionStockCharges() ?? 60;
|
||||
|
||||
private static int GetCharges(Player player, ItemIdentifier[] kinds)
|
||||
{
|
||||
return (int)(player.Inventory?.Items
|
||||
.Where(i => i.Definition?.Group == 14 && i.Definition.Number == potionNumber)
|
||||
.Where(i => i.Definition is { } definition && kinds.Contains(new ItemIdentifier(definition.Number, definition.Group)))
|
||||
.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.
|
||||
/// Finds the best offer for a potion list: the highest priority kind the merchant has and the bot can
|
||||
/// afford, and of that kind the biggest stack - one purchase of a 255 charge stack beats twenty
|
||||
/// purchases of a stack of three, in Zen spent per charge as well as in backpack slots used.
|
||||
/// </summary>
|
||||
private static bool IsSellableJunk(OfflinePlayer player, Item item)
|
||||
private static Item? FindBestOffer(Player player, ItemStorage store, ItemIdentifier[] priority)
|
||||
{
|
||||
if (item.ItemSlot < InventoryConstants.EquippableSlotsCount
|
||||
|| item.Definition is not { } definition)
|
||||
foreach (var identifier in priority)
|
||||
{
|
||||
// No reserve for potions on purpose: they are what keeps the bot alive and hunting, so
|
||||
// spending the last coin on them is right. Holding a reserve back here meant a bot which
|
||||
// the repair had left just under it walked to the merchant and bought nothing at all.
|
||||
if (FindAffordableOffer(player, store, identifier, 0) is { } offer)
|
||||
{
|
||||
return offer;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Item? FindAffordableOffer(Player player, ItemStorage store, ItemIdentifier identifier, int reserve)
|
||||
{
|
||||
return store.Items
|
||||
.Where(i => Matches(i, identifier))
|
||||
.Where(i => PriceCalculator.CalculateFinalBuyingPrice(i) + reserve <= player.Money)
|
||||
.OrderByDescending(i => i.Durability)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ranks a merchant by what the bot needs right now: the one which sells its potions wins while they
|
||||
/// run low, otherwise a jewel seller is worth the walk. Without this a bot with a full potion stock
|
||||
/// still always walked to the potion girl, past the merchant which had what it actually wanted.
|
||||
/// </summary>
|
||||
private static int ScoreMerchant(OfflinePlayer player, ItemStorage store)
|
||||
{
|
||||
var needsPotions = GetLowPotionKinds(player).Any();
|
||||
var sellsPotions = SellsPotions(store);
|
||||
var sellsJewels = BotJewelHandler.UsableJewels
|
||||
.Any(identifier => store.Items.Any(i => Matches(i, identifier)));
|
||||
|
||||
var score = 0;
|
||||
if (sellsPotions)
|
||||
{
|
||||
score += needsPotions ? 2 : 1;
|
||||
}
|
||||
|
||||
if (sellsJewels)
|
||||
{
|
||||
score += needsPotions ? 1 : 2;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A jewel which reached the junk list: either a kind the bot can never spend, or the part of a
|
||||
/// usable kind above its stock limit. Unlike a piece of gear it has no second life - the bot cannot
|
||||
/// wear it, craft with it or trade it - so when the money limit refuses the sale there is nothing
|
||||
/// left to wait for.
|
||||
/// </summary>
|
||||
private static bool IsDeadWeight(Item item)
|
||||
{
|
||||
if (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;
|
||||
var identifier = new ItemIdentifier(definition.Number, definition.Group);
|
||||
return BotJewelHandler.UsableJewels.Contains(identifier)
|
||||
|| BotJewelHandler.UnusableJewels.Contains(identifier);
|
||||
}
|
||||
|
||||
private static bool SellsPotions(DataModel.Entities.ItemStorage store)
|
||||
{
|
||||
return store.Items.Any(i => i.Definition?.Group == 14 && i.Definition.Number is 3 or 6);
|
||||
}
|
||||
private static bool SellsPotions(ItemStorage store)
|
||||
=> HealingHandler.HealthPotionPriority.Concat(HealingHandler.ManaPotionPriority)
|
||||
.Any(identifier => store.Items.Any(i => Matches(i, identifier)));
|
||||
|
||||
private static bool Matches(Item item, ItemIdentifier identifier)
|
||||
=> item.Definition is { } definition && identifier == new ItemIdentifier(definition.Number, definition.Group);
|
||||
|
||||
private static bool IsUnderSlotPressure(IStorage inventory)
|
||||
=> inventory.FreeSlots.Count() < FreeSlotPressure;
|
||||
}
|
||||
|
||||
@@ -31,19 +31,16 @@ public class BotSkillProgressionPlugIn : ICharacterLevelUpPlugIn
|
||||
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.
|
||||
/// Applies the progression a bot is owed but has not received, when it enters the world. Both stat
|
||||
/// points and skills are otherwise only handed out on a level-up, so anything a bot became entitled
|
||||
/// to while it was not playing waits for its next one - and a bot at the maximum level has no next
|
||||
/// one. That covers a freshly generated character, one whose level-up handler failed, and a bot which
|
||||
/// qualifies for a skill it was not allowed to learn when it last levelled.
|
||||
/// </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);
|
||||
}
|
||||
CatchUp.CharacterLeveledUp(player);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
namespace MUnique.OpenMU.GameLogic.Offline;
|
||||
|
||||
using MUnique.OpenMU.DataModel.Entities;
|
||||
using MUnique.OpenMU.GameLogic.Bots;
|
||||
using MUnique.OpenMU.GameLogic.MuHelper;
|
||||
using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
|
||||
using MUnique.OpenMU.GameLogic.PlugIns;
|
||||
@@ -114,7 +115,8 @@ public sealed class BuffHandler
|
||||
}
|
||||
|
||||
var skillEntry = this._player.SkillList?.GetSkill((ushort)buffId);
|
||||
if (skillEntry?.Skill?.MagicEffectDef is null)
|
||||
if (skillEntry?.Skill?.MagicEffectDef is null
|
||||
|| !this.CanCast(skillEntry.Skill))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -132,6 +134,22 @@ public sealed class BuffHandler
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the character currently meets the skill's own requirements, and can therefore cast it
|
||||
/// at all. A character keeps its skills across a reset but not the level which unlocked them, so a
|
||||
/// veteran back at level 12 still owns Swell Life, which asks for level 120.
|
||||
/// <para>
|
||||
/// Skipping it here is what keeps the whole helper running. A buff which targets the party goes
|
||||
/// through the skill plugin, which refuses an unmet requirement deep inside and silently - and this
|
||||
/// handler reports it as applied regardless. The buff step then ends every tick believing it had
|
||||
/// just buffed, so the steps behind it, picking up loot and attacking, never ran at all: the
|
||||
/// character stood in the world doing nothing, for good.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="skill">The skill to cast.</param>
|
||||
private bool CanCast(Skill skill)
|
||||
=> BotProgression.MeetsRequirements(skill, attribute => this._player.Attributes?[attribute]);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to apply the buff to self and, if applicable, to party members.
|
||||
/// </summary>
|
||||
|
||||
@@ -23,6 +23,12 @@ public sealed class CombatHandler
|
||||
private const byte DefaultRange = 1;
|
||||
private const byte BowRange = 6;
|
||||
|
||||
/// <summary>Item group of the Horn of Fenrir, the pet behind <see cref="DamageType.Fenrir"/>.</summary>
|
||||
private const byte FenrirItemGroup = 13;
|
||||
|
||||
/// <summary>Item number of the Horn of Fenrir within <see cref="FenrirItemGroup"/>.</summary>
|
||||
private const short FenrirItemNumber = 37;
|
||||
|
||||
/// <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
|
||||
@@ -66,6 +72,23 @@ public sealed class CombatHandler
|
||||
/// <summary>After this many consecutive failed approaches the target counts as unreachable.</summary>
|
||||
private const int MaxApproachFailures = 3;
|
||||
|
||||
/// <summary>
|
||||
/// A skill scoring at least this share of the best score counts as equally good, and reach decides
|
||||
/// between them. Deliberately narrow: it is meant to level out the flat bonus of comparable spells,
|
||||
/// not to trade away a skill which is genuinely stronger.
|
||||
/// </summary>
|
||||
private const float EquivalentSkillScoreShare = 0.9f;
|
||||
|
||||
/// <summary>
|
||||
/// How many monsters an area skill is credited with at most. A pack does make one worth more than a
|
||||
/// single-target skill, but not without bound - the extra targets are usually spread over the area,
|
||||
/// and not all of them are actually caught.
|
||||
/// </summary>
|
||||
private const int MaxScoredAreaTargets = 5;
|
||||
|
||||
/// <summary>The distance around the current target within which monsters count as one pack.</summary>
|
||||
private const int AreaSkillClusterRange = 3;
|
||||
|
||||
private const short DrainLifeBaseSkillId = 214;
|
||||
private const short DrainLifeStrengthenerSkillId = 458;
|
||||
private const short DrainLifeMasterySkillId = 462;
|
||||
@@ -90,6 +113,7 @@ public sealed class CombatHandler
|
||||
|
||||
private IAttackable? _currentTarget;
|
||||
private int _nearbyMonsterCount;
|
||||
private int _targetsAroundCurrent = 1;
|
||||
private int _currentComboStep;
|
||||
private int _skillCooldownTicks;
|
||||
private int _approachFailures;
|
||||
@@ -97,6 +121,7 @@ public sealed class CombatHandler
|
||||
private DateTime _unreachableTargetUntilUtc = DateTime.MinValue;
|
||||
private SkillEntry? _tickBestSkill;
|
||||
private bool _tickBestSkillComputed;
|
||||
|
||||
private DateTime _engageAtUtc = DateTime.MinValue;
|
||||
|
||||
/// <summary>
|
||||
@@ -370,6 +395,21 @@ public sealed class CombatHandler
|
||||
/// 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>
|
||||
/// <summary>
|
||||
/// Counts the monsters standing close enough to the target to be caught by an area skill aimed at it.
|
||||
/// This is what decides whether the bot swings around itself or picks its single-target skill - the
|
||||
/// hunting range is far too wide an area to answer that: it holds every monster of the ground.
|
||||
/// </summary>
|
||||
private static int CountPackAround(IAttackable? target, List<IAttackable> targets)
|
||||
{
|
||||
if (target is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return targets.Count(m => m.GetDistanceTo(target) <= AreaSkillClusterRange);
|
||||
}
|
||||
|
||||
private static float GetAttackPower(Player player)
|
||||
{
|
||||
if (player.Attributes is not { } attributes)
|
||||
@@ -418,6 +458,7 @@ public sealed class CombatHandler
|
||||
}
|
||||
|
||||
this._player.Rotation = this._player.GetDirectionTo(target);
|
||||
this._player.LastAttackUtc = DateTime.UtcNow;
|
||||
|
||||
if (skillEntry?.Skill is not { } skill)
|
||||
{
|
||||
@@ -478,10 +519,13 @@ public sealed class CombatHandler
|
||||
// character's hunting efficiency, not a crowd to camouflage.
|
||||
this._currentTarget = candidates.SelectRandom();
|
||||
this._nearbyMonsterCount = targets.Count;
|
||||
this._targetsAroundCurrent = CountPackAround(this._currentTarget, targets);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._nearbyMonsterCount = this.GetAttackableTargetsInHuntingRange().Count();
|
||||
var targets = this.GetAttackableTargetsInHuntingRange().ToList();
|
||||
this._nearbyMonsterCount = targets.Count;
|
||||
this._targetsAroundCurrent = CountPackAround(this._currentTarget, targets);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,34 +765,92 @@ public sealed class CombatHandler
|
||||
return null;
|
||||
}
|
||||
|
||||
SkillEntry? best = null;
|
||||
var bestDamage = 0;
|
||||
var ridesFenrir = this.RidesFenrir();
|
||||
var candidates = new List<(SkillEntry Entry, float Score)>();
|
||||
foreach (var entry in skillList.Skills)
|
||||
{
|
||||
if (entry.Skill is not { } skill || skill.AttackDamage <= 0)
|
||||
if (entry.Skill is not { } skill
|
||||
|| !BotProgression.IsAttackSkill(skill)
|
||||
|| BotProgression.IsCastleSiegeOnly(skill)
|
||||
|| (BotProgression.RequiresPet(skill) && !ridesFenrir)
|
||||
|| skill.Range == 0
|
||||
|
||||
// Same trap as the buffs: a character keeps its skills across a reset but not the level
|
||||
// which unlocked them, and the cast is refused deep inside, silently. A single-target
|
||||
// skill picked here would simply not go off - the basic attack only steps in when NO
|
||||
// skill was selected, not when the selected one fails.
|
||||
|| !BotProgression.MeetsRequirements(skill, attribute => this._player.Attributes?[attribute])
|
||||
|| !this.HasEnoughResources(entry))
|
||||
{
|
||||
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;
|
||||
}
|
||||
candidates.Add((entry, this.ScoreSkill(skill)));
|
||||
}
|
||||
|
||||
this._tickBestSkill = best;
|
||||
return best;
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
this._tickBestSkill = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Among skills which are worth about the same, reach decides. The flat bonus of a skill is added
|
||||
// to the character's own damage, so at a few thousand base damage the gap between the strongest
|
||||
// spell and the second strongest is a rounding error - while three tiles of range are three tiles
|
||||
// whether the character is level 20 or 400. This is what stopped every wizard from fighting at
|
||||
// arm's length with Hellfire.
|
||||
var bestScore = candidates.Max(c => c.Score);
|
||||
return this._tickBestSkill = candidates
|
||||
.Where(c => c.Score >= bestScore * EquivalentSkillScoreShare)
|
||||
.OrderByDescending(c => c.Entry.Skill!.Range)
|
||||
.ThenByDescending(c => c.Entry.Skill!.MasterDefinition is not null)
|
||||
.ThenByDescending(c => c.Score)
|
||||
.First()
|
||||
.Entry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimates what one cast of the skill is worth right now: the damage of a single hit, times the
|
||||
/// number of hits the skill performs, times the number of monsters an area skill would catch.
|
||||
/// <see cref="Skill.AttackDamage"/> alone does not say it - it is a flat bonus added to the
|
||||
/// character's base damage, so a skill can carry none at all and still be the strongest thing the
|
||||
/// class owns, which is exactly the case for a Rage Fighter's four-hit skills.
|
||||
/// </summary>
|
||||
private float ScoreSkill(Skill skill)
|
||||
{
|
||||
var attributes = this._player.Attributes;
|
||||
var baseDamage = skill.DamageType switch
|
||||
{
|
||||
DamageType.Wizardry => attributes?[Stats.MaximumWizBaseDmg] ?? 0,
|
||||
DamageType.Curse => attributes?[Stats.MaximumCurseBaseDmg] ?? 0,
|
||||
|
||||
// Only reached when the character actually rides a Fenrir - the skill is filtered out
|
||||
// otherwise, because this attribute is derived from the character's own stats and says
|
||||
// nothing about whether the pet is there (see RidesFenrir).
|
||||
DamageType.Fenrir => attributes?[Stats.FenrirBaseDmg] ?? 0,
|
||||
_ => attributes?[Stats.MaximumPhysBaseDmg] ?? 0,
|
||||
};
|
||||
|
||||
var perHit = baseDamage + skill.AttackDamage;
|
||||
var hits = Math.Max((int)skill.NumberOfHitsPerAttack, 1);
|
||||
var targets = BotProgression.IsAreaSkill(skill)
|
||||
? Math.Clamp(this._targetsAroundCurrent, 1, MaxScoredAreaTargets)
|
||||
: 1;
|
||||
|
||||
return perHit * hits * targets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the character actually rides a Fenrir, which the skills reported by
|
||||
/// <see cref="BotProgression.RequiresPet"/> need in order to be worth anything.
|
||||
/// </summary>
|
||||
private bool RidesFenrir()
|
||||
=> this._player.Inventory?.GetItem(InventoryConstants.PetSlot) is
|
||||
{
|
||||
Durability: > 0.0,
|
||||
Definition: { Group: FenrirItemGroup, Number: FenrirItemNumber },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether the skill in the given slot should fire this tick.
|
||||
/// </summary>
|
||||
|
||||
@@ -18,12 +18,12 @@ 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 =
|
||||
/// <summary>
|
||||
/// The health potions the offline player drinks, best first. Also the shopping list a bot restocks
|
||||
/// from (see <c>BotShoppingHandler</c>): buying what it does not drink, or not buying what it does,
|
||||
/// is how a bot ends up starving next to a full merchant.
|
||||
/// </summary>
|
||||
internal static readonly ItemIdentifier[] HealthPotionPriority =
|
||||
[
|
||||
ItemConstants.LargeHealingPotion,
|
||||
ItemConstants.MediumHealingPotion,
|
||||
@@ -31,13 +31,21 @@ public sealed class HealingHandler
|
||||
ItemConstants.Apple,
|
||||
];
|
||||
|
||||
private static readonly ItemIdentifier[] ManaPotionPriority =
|
||||
/// <summary>
|
||||
/// The mana potions the offline player drinks, best first. <see cref="HealthPotionPriority"/>.
|
||||
/// </summary>
|
||||
internal static readonly ItemIdentifier[] ManaPotionPriority =
|
||||
[
|
||||
ItemConstants.LargeManaPotion,
|
||||
ItemConstants.MediumManaPotion,
|
||||
ItemConstants.SmallManaPotion,
|
||||
];
|
||||
|
||||
/// <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 readonly OfflinePlayer _player;
|
||||
private readonly IMuHelperSettings? _config;
|
||||
|
||||
|
||||
@@ -117,7 +117,11 @@ public sealed class ItemPickupHandler
|
||||
|
||||
if (this._config.PickJewel && IsJewel(item))
|
||||
{
|
||||
return true;
|
||||
// A human's helper takes every jewel - its owner trades, crafts or hoards them later. A bot
|
||||
// has no later: it can only spend Bless, Soul and Life on its own gear, so a Jewel of Chaos
|
||||
// or a stock-exceeding Soul is a backpack slot it never gets back.
|
||||
return this._player.Account?.IsBot != true
|
||||
|| Bots.BotJewelHandler.WantsMoreOf(this._player, item);
|
||||
}
|
||||
|
||||
var isAncient = item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0);
|
||||
|
||||
@@ -157,6 +157,15 @@ public class OfflinePlayer : Player
|
||||
/// </summary>
|
||||
internal bool HasRevengeIntent => this._revenge is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time the character last struck something. It is the only honest answer to
|
||||
/// "is this map paying off": whatever keeps a bot from fighting - monsters it may not engage,
|
||||
/// grounds another bot empties first, a map its level opened but its body cannot handle - the
|
||||
/// symptom is the same, and so is the remedy (see <see cref="Bots.BotNavigator"/>: move to easier
|
||||
/// ground rather than walk between hunting grounds forever).
|
||||
/// </summary>
|
||||
internal DateTime LastAttackUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the offline player by loading the account fresh from the database.
|
||||
/// </summary>
|
||||
|
||||
@@ -28,7 +28,8 @@ public class SellItemToNpcAction
|
||||
/// </summary>
|
||||
/// <param name="player">The player.</param>
|
||||
/// <param name="slot">The slot.</param>
|
||||
public async ValueTask SellItemAsync(Player player, byte slot)
|
||||
/// <returns><c>True</c>, if the item was sold; otherwise, <c>false</c>.</returns>
|
||||
public async ValueTask<bool> SellItemAsync(Player player, byte slot)
|
||||
{
|
||||
using var loggerScope = player.Logger.BeginScope(this.GetType());
|
||||
var item = player.Inventory?.GetItem(slot);
|
||||
@@ -36,37 +37,45 @@ public class SellItemToNpcAction
|
||||
{
|
||||
player.Logger.LogWarning("Player {0} requested to sell item at slot {1}, but item wasn't found.", player, slot);
|
||||
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(false)).ConfigureAwait(false);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (player.OpenedNpc?.Definition.MerchantStore is null)
|
||||
{
|
||||
player.Logger.LogWarning("Player {0} requested to sell item at slot {1} to an npc, but no npc merchant store is currently opened.", player, slot);
|
||||
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(false)).ConfigureAwait(false);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.Definition is null || (item.Definition.IsBoundToCharacter && (item.Definition.Durability == 0 || item.Durability > 0)))
|
||||
{
|
||||
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(false)).ConfigureAwait(false);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.SellItemAsync(player, item).ConfigureAwait(false);
|
||||
return await this.SellItemAsync(player, item).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask SellItemAsync(Player player, Item item)
|
||||
private async ValueTask<bool> SellItemAsync(Player player, Item item)
|
||||
{
|
||||
var sellingPrice = (int)this._itemPriceCalculator.CalculateSellingPrice(item, item.Durability());
|
||||
player.Logger.LogDebug("Calculated selling price {0} for item {1}", sellingPrice, item);
|
||||
if (player.TryAddMoney(sellingPrice))
|
||||
if (!player.TryAddMoney(sellingPrice))
|
||||
{
|
||||
player.Logger.LogDebug("Sold Item {0} for price: {1}", item, sellingPrice);
|
||||
await player.Inventory!.RemoveItemAsync(item).ConfigureAwait(false);
|
||||
await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false);
|
||||
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(true)).ConfigureAwait(false);
|
||||
|
||||
player.GameContext.PlugInManager.GetPlugInPoint<IItemSoldToMerchantPlugIn>()?.ItemSold(player, item, player.OpenedNpc!);
|
||||
// The money doesn't fit into the inventory anymore. Without the answer the request would
|
||||
// stay unanswered - the client keeps waiting, and the player gets no hint why nothing
|
||||
// happened. All other refusals above already report back this way.
|
||||
player.Logger.LogDebug("Item {0} not sold, the money of player {1} is at its maximum.", item, player);
|
||||
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(false)).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
player.Logger.LogDebug("Sold Item {0} for price: {1}", item, sellingPrice);
|
||||
await player.Inventory!.RemoveItemAsync(item).ConfigureAwait(false);
|
||||
await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false);
|
||||
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(true)).ConfigureAwait(false);
|
||||
|
||||
player.GameContext.PlugInManager.GetPlugInPoint<IItemSoldToMerchantPlugIn>()?.ItemSold(player, item, player.OpenedNpc!);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
49
tests/MUnique.OpenMU.Tests/BotHuntingGroundTest.cs
Normal file
49
tests/MUnique.OpenMU.Tests/BotHuntingGroundTest.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
// <copyright file="BotHuntingGroundTest.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.GameLogic.Bots;
|
||||
using MUnique.OpenMU.Pathfinding;
|
||||
using NUnit.Framework;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the way a bot picks the spot it hunts on.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class BotHuntingGroundTest
|
||||
{
|
||||
/// <summary>
|
||||
/// A hunting ground on the far side of the map stays a realistic choice. Bots enter a map through the
|
||||
/// same gate and rate every ground from that one spot, so a steep distance penalty made all of them
|
||||
/// queue up on the few spawns next to the entrance.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void DistantGroundKeepsARealisticShare()
|
||||
{
|
||||
var atTheGate = new MonsterSpawnArea { X1 = 200, X2 = 200, Y1 = 200, Y2 = 200, Quantity = 1 };
|
||||
var acrossTheMap = new MonsterSpawnArea { X1 = 33, X2 = 33, Y1 = 95, Y2 = 95, Quantity = 1 };
|
||||
var gate = new Point(222, 211);
|
||||
|
||||
var near = BotNavigator.GroundWeight(atTheGate, gate);
|
||||
var far = BotNavigator.GroundWeight(acrossTheMap, gate);
|
||||
|
||||
Assert.That(near, Is.GreaterThan(far), "closer grounds are still preferred");
|
||||
Assert.That(near, Is.LessThan(far * 10), "but not to the point where the rest of the map never gets picked");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The number of monsters an area holds still counts.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void DenserGroundOutweighsThinnerOneAtTheSameDistance()
|
||||
{
|
||||
var thin = new MonsterSpawnArea { X1 = 100, X2 = 100, Y1 = 100, Y2 = 100, Quantity = 1 };
|
||||
var dense = new MonsterSpawnArea { X1 = 100, X2 = 100, Y1 = 100, Y2 = 100, Quantity = 20 };
|
||||
var from = new Point(150, 150);
|
||||
|
||||
Assert.That(BotNavigator.GroundWeight(dense, from), Is.GreaterThan(BotNavigator.GroundWeight(thin, from)));
|
||||
}
|
||||
}
|
||||
@@ -35,12 +35,11 @@ public class BotJewelHandlerTest
|
||||
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);
|
||||
var plan = BotJewelHandler.PlanNextUse(player, 2, 1, 1);
|
||||
|
||||
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>
|
||||
@@ -59,7 +58,7 @@ public class BotJewelHandlerTest
|
||||
await AddJewelAsync(player, (byte)(FirstBackpackSlot + i), ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var plan = BotJewelHandler.PlanNextUse(player, false);
|
||||
var plan = BotJewelHandler.PlanNextUse(player, 2, 1, 1);
|
||||
|
||||
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
|
||||
}
|
||||
@@ -76,7 +75,7 @@ public class BotJewelHandlerTest
|
||||
await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||
|
||||
var plan = BotJewelHandler.PlanNextUse(player, false);
|
||||
var plan = BotJewelHandler.PlanNextUse(player, 2, 1, 1);
|
||||
|
||||
Assert.That(plan, Is.Not.Null);
|
||||
Assert.That(plan!.Value.Target, Is.SameAs(luckyPiece));
|
||||
@@ -100,7 +99,7 @@ public class BotJewelHandlerTest
|
||||
await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||
|
||||
var plan = BotJewelHandler.PlanNextUse(player, false);
|
||||
var plan = BotJewelHandler.PlanNextUse(player, 2, 1, 1);
|
||||
|
||||
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
|
||||
}
|
||||
@@ -116,19 +115,43 @@ public class BotJewelHandlerTest
|
||||
{
|
||||
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);
|
||||
var life = await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfLife).ConfigureAwait(false);
|
||||
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfLife).ConfigureAwait(false);
|
||||
|
||||
var plan = BotJewelHandler.PlanNextUse(player, lifeAlreadyUsed);
|
||||
var plan = BotJewelHandler.PlanNextUse(player, 2, 1, lifeAlreadyUsed ? 0 : 1);
|
||||
|
||||
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
|
||||
if (expectsUse)
|
||||
{
|
||||
Assert.That(plan!.Value.Target, Is.SameAs(target));
|
||||
Assert.That(plan.Value.IsLife, Is.True);
|
||||
Assert.That(plan.Value.Jewel, Is.SameAs(life));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Each jewel kind has its own budget: a spent Bless budget must not keep the Soul rule from
|
||||
/// being reached. With one shared budget the Bless rule - which has a target whenever any equipped
|
||||
/// piece is below +6 - consumed every use of every trip, and Souls and Lifes piled up unused.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask SpentBlessBudgetDoesNotStarveSoulAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
|
||||
// A Bless target (below +6) and a Soul target (+6) are available at the same time.
|
||||
await AddEquippedItemAsync(player, InventoryConstants.RightHandSlot, 2).ConfigureAwait(false);
|
||||
var soulTarget = await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 6).ConfigureAwait(false);
|
||||
await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfBless).ConfigureAwait(false);
|
||||
var soul = await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||
await AddJewelAsync(player, FirstBackpackSlot + 2, ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||
|
||||
var plan = BotJewelHandler.PlanNextUse(player, 0, 1, 1);
|
||||
|
||||
Assert.That(plan, Is.Not.Null);
|
||||
Assert.That(plan!.Value.Jewel, Is.SameAs(soul));
|
||||
Assert.That(plan.Value.Target, Is.SameAs(soulTarget));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Without any applicable jewel or target, nothing is planned.
|
||||
/// </summary>
|
||||
@@ -138,7 +161,7 @@ public class BotJewelHandlerTest
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 2).ConfigureAwait(false);
|
||||
|
||||
var plan = BotJewelHandler.PlanNextUse(player, false);
|
||||
var plan = BotJewelHandler.PlanNextUse(player, 2, 1, 1);
|
||||
|
||||
Assert.That(plan, Is.Null);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,38 @@ public class BotServerPartitionTest
|
||||
Assert.That(BotServerPartition.Split(capacities, 2, 150).Partition.IsGenerator, Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A deployment which is configured to hold NO bots still has a server which generates - and, above
|
||||
/// all, purges - the population. Deciding the role by the number of accounts instead would mean that
|
||||
/// "delete all bots" does nothing at all on exactly the setting which asks for no bots.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void TheFirstServerActsEvenWithoutAnyAccounts()
|
||||
{
|
||||
List<(byte ServerId, int Capacity)> capacities = [(0, 50), (1, 50)];
|
||||
|
||||
var (first, assigned) = BotServerPartition.Split(capacities, 0, 0);
|
||||
var (second, _) = BotServerPartition.Split(capacities, 1, 0);
|
||||
|
||||
Assert.That(assigned, Is.EqualTo(0));
|
||||
Assert.That(first.AccountCount, Is.EqualTo(0));
|
||||
Assert.That(first.IsGenerator, Is.True);
|
||||
Assert.That(second.IsGenerator, Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The role is held by one server even when the deployment has no bot capacity left at all, so a
|
||||
/// purge still finds someone to carry it out.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void TheFirstServerActsWithoutAnyCapacity()
|
||||
{
|
||||
List<(byte ServerId, int Capacity)> capacities = [(0, 0), (1, 0)];
|
||||
|
||||
Assert.That(BotServerPartition.Split(capacities, 0, 100).Partition.IsGenerator, Is.True);
|
||||
Assert.That(BotServerPartition.Split(capacities, 1, 100).Partition.IsGenerator, Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The servers may have different player limits; the shares follow their capacity.
|
||||
/// </summary>
|
||||
|
||||
126
tests/MUnique.OpenMU.Tests/BotSkillRepertoireTest.cs
Normal file
126
tests/MUnique.OpenMU.Tests/BotSkillRepertoireTest.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
// <copyright file="BotSkillRepertoireTest.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.GameLogic.Bots;
|
||||
using NUnit.Framework;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the skills a bot may learn - the gate which decides what it owns to fight with.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class BotSkillRepertoireTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests that the castle siege skills are refused. They carry the highest damage numbers of their
|
||||
/// classes, so a "strongest first" rule walks straight into them, and the game activates them
|
||||
/// nowhere but the siege map.
|
||||
/// </summary>
|
||||
/// <param name="skillNumber">The number of the castle siege skill.</param>
|
||||
[TestCase((short)44)] // Crescent Moon Slash
|
||||
[TestCase((short)45)] // Lance
|
||||
[TestCase((short)46)] // Starfall
|
||||
[TestCase((short)57)] // Spiral Slash
|
||||
[TestCase((short)73)] // Mana Rays
|
||||
[TestCase((short)74)] // Fire Blast
|
||||
[TestCase((short)269)] // Charge
|
||||
public void CastleSiegeSkillIsNotLearned(short skillNumber)
|
||||
{
|
||||
var skill = CreateAttackSkill(skillNumber, attackDamage: 120);
|
||||
|
||||
Assert.That(BotProgression.IsBotLearnableSkill(skill), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the skills of the siege roles stay out as well. Stun is the one that matters: it is an
|
||||
/// area skill with no damage and a single hit, which is exactly what Twisting Slash looks like, so
|
||||
/// nothing but its number tells the two apart.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void SiegeRoleSkillIsNotLearned()
|
||||
{
|
||||
var stun = CreateAttackSkill(67, attackDamage: 0, skillType: SkillType.AreaSkillAutomaticHits);
|
||||
|
||||
Assert.That(BotProgression.IsBotLearnableSkill(stun), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that a skill which carries no flat damage bonus but strikes several times is learned. The
|
||||
/// Rage Fighter's whole arsenal is built that way - its damage comes from the weapon - so judging by
|
||||
/// <see cref="Skill.AttackDamage"/> alone left the class with nothing to fight with.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void MultiHitSkillWithoutFlatDamageIsLearned()
|
||||
{
|
||||
var killingBlow = CreateAttackSkill(260, attackDamage: 0, hits: 4);
|
||||
|
||||
Assert.That(BotProgression.IsBotLearnableSkill(killingBlow), Is.True);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that an area skill is learned even without a flat damage bonus: its worth is the number of
|
||||
/// monsters it catches, not a bonus per hit.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void AreaSkillWithoutFlatDamageIsLearned()
|
||||
{
|
||||
var twistingSlash = CreateAttackSkill(41, attackDamage: 0, skillType: SkillType.AreaSkillAutomaticHits);
|
||||
|
||||
Assert.That(BotProgression.IsBotLearnableSkill(twistingSlash), Is.True);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that a single-hit skill without a damage bonus stays out: it is worth no more than a plain
|
||||
/// attack while costing mana. These are the combo swings, which the combo handler drives separately.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void PlainSingleHitSkillWithoutDamageIsNotLearned()
|
||||
{
|
||||
var lunge = CreateAttackSkill(20, attackDamage: 0);
|
||||
|
||||
Assert.That(BotProgression.IsBotLearnableSkill(lunge), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that an ordinary attack skill is still learned.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void OrdinaryAttackSkillIsLearned()
|
||||
{
|
||||
var evilSpirit = CreateAttackSkill(9, attackDamage: 45);
|
||||
|
||||
Assert.That(BotProgression.IsBotLearnableSkill(evilSpirit), Is.True);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that a pet's skill is recognized as one. Plasma Storm belongs to the Fenrir, but its damage
|
||||
/// attribute is derived from the character's own stats, so it looks strong on a character riding
|
||||
/// nothing - and being the longest ranged skill most classes own, it won the tie-break for the whole
|
||||
/// population until the pet was checked for.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void PetSkillIsRecognizedAsOne()
|
||||
{
|
||||
var plasmaStorm = CreateAttackSkill(76, attackDamage: 60, damageType: DamageType.Fenrir);
|
||||
var strikeOfDestruction = CreateAttackSkill(232, attackDamage: 110);
|
||||
|
||||
Assert.That(BotProgression.RequiresPet(plasmaStorm), Is.True);
|
||||
Assert.That(BotProgression.RequiresPet(strikeOfDestruction), Is.False);
|
||||
}
|
||||
|
||||
private static Skill CreateAttackSkill(short number, int attackDamage, byte hits = 1, SkillType skillType = SkillType.DirectHit, DamageType damageType = DamageType.Physical)
|
||||
{
|
||||
return new Skill
|
||||
{
|
||||
Number = number,
|
||||
AttackDamage = attackDamage,
|
||||
NumberOfHitsPerAttack = hits,
|
||||
SkillType = skillType,
|
||||
DamageType = damageType,
|
||||
Range = 6,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user