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
|
- **`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 for their resets. Off by default: they take no part in the economy those
|
||||||
costs are balanced for.
|
costs are balanced for.
|
||||||
- **`Reset bots`** — purges and regenerates the whole population on the next
|
- **`Jewel stock per kind`** — how many Jewels of Bless, Soul and Life a bot
|
||||||
start, then clears itself.
|
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
|
## 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
|
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.
|
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
|
Map access follows the game's warp list, and a bot travels on a player's terms:
|
||||||
may legally warp there, and a bot which finds itself on a map it may not be on
|
it enters a map only if its level may legally warp there, it meets the map's own
|
||||||
(after a reset, for instance) leaves for the best map it may use. The map it
|
requirements, and it pays the warp's fare out of its own Zen. A bot which finds
|
||||||
reached is persisted, so a restarted bot wakes up where it stopped.
|
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
|
### Fighting and progressing
|
||||||
|
|
||||||
A bot fights with the strongest skill of its class it has learned and can pay
|
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
|
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
|
generation and again on every level-up, and the class buffs are kept up on their
|
||||||
own.
|
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:
|
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,
|
an agility/shield meta on reset servers, guide-style builds on classic ones,
|
||||||
chosen automatically by whether the reset feature is configured. Classes with
|
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
|
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.
|
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,
|
A merchant trip is the only moment a bot can turn loot into anything, so it goes
|
||||||
sells its junk, and buys refills from the proceeds while the shop dialog visibly
|
whenever it has something to gain there: the backpack is filling with junk, the
|
||||||
occupies it — on a map without a merchant it warps home first, like a player
|
potions are running low, a jewel is waiting to be used, or a surplus is waiting
|
||||||
would. Looted Jewels of Bless, Soul and Life are spent on its own equipment
|
to be sold. Restocking needs the means to pay for it, though - Zen, or loot to
|
||||||
through the regular consume action, with the same success rates and failure
|
sell once it is there. A broke bot buys nothing, so the trip would leave it just
|
||||||
penalties a player faces, and with the caution a player shows: a Soul is only
|
as short as it set out, and it would set out again instead of hunting, which is
|
||||||
risked where a failure cannot destroy the item's level.
|
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
|
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
|
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.
|
game server, over which the population then spreads by itself.
|
||||||
3. Restart the server. The population is generated on the first start and
|
3. Restart the server. The population is generated on the first start and
|
||||||
reloaded afterwards.
|
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.
|
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.")]
|
[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; }
|
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>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether the bot population rotates its presence over the day:
|
/// 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 -
|
/// 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).")]
|
[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; }
|
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>
|
/// <summary>
|
||||||
/// Gets or sets a comma separated list of login names of existing accounts to animate as bots.
|
/// 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
|
/// This is an optional extra hook alongside the generated population (see
|
||||||
@@ -109,6 +138,22 @@ public class BotConfiguration
|
|||||||
public int GetEffectiveBotCapacityPercent()
|
public int GetEffectiveBotCapacityPercent()
|
||||||
=> Math.Clamp(this.BotCapacityPercent, 1, 100);
|
=> 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>
|
/// <summary>
|
||||||
/// Parses <see cref="ProofOfConceptAccounts"/> into the distinct, trimmed login names.
|
/// Parses <see cref="ProofOfConceptAccounts"/> into the distinct, trimmed login names.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -71,10 +71,37 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public BotConfiguration? Configuration { get; set; }
|
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 />
|
/// <inheritdoc />
|
||||||
public async ValueTask ExecuteTaskAsync(GameContext gameContext)
|
public async ValueTask ExecuteTaskAsync(GameContext gameContext)
|
||||||
{
|
{
|
||||||
var state = this._states.GetOrAdd(gameContext, _ => new ServerState());
|
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)
|
if (state.StartupState == (int)StartupPhase.Done)
|
||||||
{
|
{
|
||||||
await this.RunMaintenanceAsync(gameContext, state).ConfigureAwait(false);
|
await this.RunMaintenanceAsync(gameContext, state).ConfigureAwait(false);
|
||||||
@@ -87,14 +114,6 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus
|
|||||||
return;
|
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
|
try
|
||||||
{
|
{
|
||||||
await this.SpawnPopulationAsync(gameContext, state, configuration).ConfigureAwait(false);
|
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)
|
private async ValueTask SpawnPopulationAsync(GameContext gameContext, ServerState state, BotConfiguration configuration)
|
||||||
{
|
{
|
||||||
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
|
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 generator = new BotGenerator(gameContext, logger);
|
||||||
var partition = state.Partition = await BotServerPartition.CreateAsync(gameContext, configuration, logger).ConfigureAwait(false);
|
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)
|
if (configuration.ResetBots && partition.IsGenerator)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var deleted = await generator.DeleteAllBotsAsync().ConfigureAwait(false);
|
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.
|
// Clear the flag (in memory and persisted) so the next restart does not purge again.
|
||||||
configuration.ResetBots = false;
|
configuration.ResetBots = false;
|
||||||
|
|||||||
@@ -184,14 +184,17 @@ internal sealed class BotGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>The number of deleted bot accounts.</returns>
|
/// <returns>The number of deleted bot accounts.</returns>
|
||||||
public async ValueTask<int> DeleteAllBotsAsync(CancellationToken cancellationToken = default)
|
public async ValueTask<int> DeleteAllBotsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var context = this._gameContext.PersistenceContextProvider.CreateNewPlayerContext(this._gameContext.Configuration);
|
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;
|
const int pageSize = 100;
|
||||||
var skip = 0;
|
var skip = 0;
|
||||||
while (true)
|
while (true)
|
||||||
@@ -203,31 +206,51 @@ internal sealed class BotGenerator
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
var bots = page.Where(a => a.IsBot).ToList();
|
loginNames.AddRange(page.Where(account => account.IsBot).Select(account => account.LoginName));
|
||||||
var removed = 0;
|
skip += page.Count;
|
||||||
foreach (var bot in bots)
|
}
|
||||||
|
|
||||||
|
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++;
|
await context.DeleteAsync(inventory).ConfigureAwait(false);
|
||||||
removed++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Not silent: a bot account which survives the reset is spawned again right after
|
|
||||||
// it, and the paging below would never look at it a second time.
|
|
||||||
this._logger.LogWarning("Bot account '{LoginName}' could not be deleted for the bot reset.", bot.LoginName);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit this page's deletions before paging on, so the ordering used by the next query
|
if (account.Vault is { } vault)
|
||||||
// reflects the removals: what is left of this page now occupies [skip, skip + kept).
|
|
||||||
if (removed > 0)
|
|
||||||
{
|
{
|
||||||
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;
|
return deleted;
|
||||||
|
|||||||
@@ -24,8 +24,49 @@ using MUnique.OpenMU.GameLogic.PlayerActions.Items;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class BotJewelHandler
|
internal static class BotJewelHandler
|
||||||
{
|
{
|
||||||
/// <summary>Upper bound of jewel consumptions per shopping trip - a player doesn't burn the whole hoard at once.</summary>
|
/// <summary>
|
||||||
private const int MaxUsesPerTrip = 2;
|
/// 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>
|
/// <summary>The Jewel of Bless upgrades item levels 0..5 (see <c>BlessJewelConsumeHandlerPlugIn</c>).</summary>
|
||||||
private const byte BlessMaxTargetLevel = 5;
|
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>
|
/// <summary>Only risk a Life with at least this many in stock.</summary>
|
||||||
private const int MinLifeStock = 2;
|
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 ItemConsumeAction ConsumeAction = new();
|
||||||
private static readonly MoveItemAction MoveAction = new();
|
private static readonly MoveItemAction MoveAction = new();
|
||||||
|
|
||||||
@@ -74,8 +118,10 @@ internal static class BotJewelHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
var uses = 0;
|
var uses = 0;
|
||||||
var lifeUsed = false;
|
var blessLeft = MaxBlessPerTrip;
|
||||||
while (uses < MaxUsesPerTrip && PlanNextUse(player, lifeUsed) is { } plan)
|
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))
|
if (!await ApplyJewelAsync(player, plan.Jewel, plan.Target).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
@@ -83,7 +129,18 @@ internal static class BotJewelHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
uses++;
|
uses++;
|
||||||
lifeUsed |= plan.IsLife;
|
if (IsJewel(plan.Jewel, ItemConstants.JewelOfBless))
|
||||||
|
{
|
||||||
|
blessLeft--;
|
||||||
|
}
|
||||||
|
else if (IsJewel(plan.Jewel, ItemConstants.JewelOfSoul))
|
||||||
|
{
|
||||||
|
soulLeft--;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lifeLeft--;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uses > 0)
|
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.
|
/// <c>null</c> when nothing sensible is left to do. Pure decision logic - exposed for unit tests.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="player">The bot player.</param>
|
/// <param name="player">The bot player.</param>
|
||||||
/// <param name="lifeUsed">Whether a Jewel of Life was already used this trip (at most one).</param>
|
/// <param name="blessLeft">How many Jewels of Bless may still be used this trip.</param>
|
||||||
internal static (Item Jewel, Item Target, bool IsLife)? PlanNextUse(Player player, bool lifeUsed)
|
/// <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)
|
if (player.Inventory is not { } inventory)
|
||||||
{
|
{
|
||||||
@@ -125,17 +184,19 @@ internal static class BotJewelHandler
|
|||||||
var lifeStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfLife)).ToList();
|
var lifeStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfLife)).ToList();
|
||||||
|
|
||||||
// 1. Bless - free progress: push the weakest equipped piece towards +6.
|
// 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)
|
&& equipped.Where(i => i.CanLevelBeUpgraded() && i.Level <= BlessMaxTargetLevel)
|
||||||
.OrderBy(i => i.Level)
|
.OrderBy(i => i.Level)
|
||||||
.FirstOrDefault() is { } blessTarget)
|
.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 -
|
// 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.
|
// 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()
|
&& equipped.Where(i => i.CanLevelBeUpgraded()
|
||||||
&& i.Level >= SoulMinTargetLevel
|
&& i.Level >= SoulMinTargetLevel
|
||||||
&& i.Level <= (HasLuck(i) ? SoulMaxTargetLevelLucky : SoulMaxTargetLevelPlain))
|
&& i.Level <= (HasLuck(i) ? SoulMaxTargetLevelLucky : SoulMaxTargetLevelPlain))
|
||||||
@@ -143,24 +204,118 @@ internal static class BotJewelHandler
|
|||||||
.ThenBy(i => i.Level)
|
.ThenBy(i => i.Level)
|
||||||
.FirstOrDefault() is { } soulTarget)
|
.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
|
// 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
|
// the item can actually carry the option is the consume handler's call; a rejected consume
|
||||||
// keeps the jewel.
|
// keeps the jewel.
|
||||||
if (!lifeUsed
|
if (lifeLeft > 0
|
||||||
&& lifeStock.Count >= MinLifeStock
|
&& lifeStock.Count >= MinLifeStock
|
||||||
&& equipped.Where(i => i.IsWearable() && i.Level >= LifeMinTargetLevel)
|
&& equipped.Where(i => i.IsWearable() && i.Level >= LifeMinTargetLevel)
|
||||||
.OrderByDescending(i => i.Level)
|
.OrderByDescending(i => i.Level)
|
||||||
.FirstOrDefault() is { } lifeTarget)
|
.FirstOrDefault() is { } lifeTarget)
|
||||||
{
|
{
|
||||||
return (lifeStock[0], lifeTarget, true);
|
return (lifeStock[0], lifeTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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>
|
/// <summary>
|
||||||
/// Applies one jewel to one equipped item the way a player does it: take the piece off into a free
|
/// 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
|
/// backpack slot (the consume handlers refuse to modify equipped items), consume the jewel on it
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ public sealed class BotManager
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyCollection<BotPlayer> Bots => this._bots.Values.ToList();
|
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>
|
/// <summary>
|
||||||
/// Spawns a bot which drives a specific character of the given account.
|
/// Spawns a bot which drives a specific character of the given account.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -43,6 +43,24 @@ internal sealed class BotNavigator : AsyncDisposable
|
|||||||
/// <summary>Range (tiles) around the origin that counts as "at the hunting ground".</summary>
|
/// <summary>Range (tiles) around the origin that counts as "at the hunting ground".</summary>
|
||||||
private const int HuntingRange = 6;
|
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>
|
/// <summary>
|
||||||
/// How far the bot looks for an actual live monster to home in on. MU spawn areas span most of the map
|
/// 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
|
/// (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>
|
/// </summary>
|
||||||
private const int DeathSiteAvoidanceRange = 30;
|
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 EvaluationInterval = TimeSpan.FromSeconds(1);
|
||||||
private static readonly TimeSpan EmptyGroundGrace = TimeSpan.FromSeconds(8);
|
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>
|
/// <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);
|
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>
|
/// <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);
|
private static readonly TimeSpan FollowWarpCooldown = TimeSpan.FromSeconds(20);
|
||||||
|
|
||||||
@@ -198,7 +236,14 @@ internal sealed class BotNavigator : AsyncDisposable
|
|||||||
private int _travelPathIndex;
|
private int _travelPathIndex;
|
||||||
private Point _travelPathTarget;
|
private Point _travelPathTarget;
|
||||||
private Point? _shoppingTarget;
|
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 DateTime? _resetDueAtUtc;
|
||||||
private short _leaderMapNumber;
|
private short _leaderMapNumber;
|
||||||
private DateTime _leaderOnMapSinceUtc = DateTime.MinValue;
|
private DateTime _leaderOnMapSinceUtc = DateTime.MinValue;
|
||||||
@@ -230,6 +275,24 @@ internal sealed class BotNavigator : AsyncDisposable
|
|||||||
EvaluationInterval);
|
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 />
|
/// <inheritdoc />
|
||||||
protected override async ValueTask DisposeAsyncCore()
|
protected override async ValueTask DisposeAsyncCore()
|
||||||
{
|
{
|
||||||
@@ -274,13 +337,18 @@ internal sealed class BotNavigator : AsyncDisposable
|
|||||||
private static int GetMonsterLevel(MonsterDefinition definition)
|
private static int GetMonsterLevel(MonsterDefinition definition)
|
||||||
=> (int)(definition.Attributes.FirstOrDefault(a => a.AttributeDefinition == Stats.Level)?.Value ?? 0f);
|
=> (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));
|
var spread = 1 + (((Rand.NextDouble() * 2) - 1) * ShoppingSpread);
|
||||||
return Math.Max(1, (int)area.Quantity) * proximity;
|
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)
|
private static int GroundDistance(MonsterSpawnArea area, Point from)
|
||||||
{
|
{
|
||||||
var centerX = (area.X1 + area.X2) / 2;
|
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._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._shoppingTarget = null;
|
||||||
this._player.IsOnShoppingTrip = false;
|
this._player.IsOnShoppingTrip = false;
|
||||||
this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCooldown;
|
this._nextShoppingCheckUtc = NextShoppingCheckUtc();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -579,7 +647,7 @@ internal sealed class BotNavigator : AsyncDisposable
|
|||||||
|
|
||||||
this._shoppingTarget = null;
|
this._shoppingTarget = null;
|
||||||
this._player.IsOnShoppingTrip = false;
|
this._player.IsOnShoppingTrip = false;
|
||||||
this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCooldown;
|
this._nextShoppingCheckUtc = NextShoppingCheckUtc();
|
||||||
this._lastMoveUtc = DateTime.UtcNow; // standing at the shop is not "stuck"
|
this._lastMoveUtc = DateTime.UtcNow; // standing at the shop is not "stuck"
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -595,7 +663,7 @@ internal sealed class BotNavigator : AsyncDisposable
|
|||||||
return false;
|
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
|
// 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
|
// 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;
|
ExitGate? warpListGate = null;
|
||||||
if (leader.CurrentMap is { } targetMap
|
if (leader.CurrentMap is { } targetMap
|
||||||
&& !this.TryGetLegalWarpGate(targetMap.Definition, out warpListGate)
|
&& (this.TryGetLegalWarp(targetMap.Definition, out var leaderWarp)
|
||||||
&& targetMap.Definition.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number)
|
? (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
|
// 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
|
// (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 currentMap = this._player.CurrentMap?.Definition;
|
||||||
var mapIsHostile = currentMap is not null && this.BestSafeLevel(currentMap) == 0;
|
var mapIsHostile = currentMap is not null && this.BestSafeLevel(currentMap) == 0;
|
||||||
var mapIsIllegal = currentMap is not null
|
var mapIsIllegal = currentMap is not null
|
||||||
&& !this.TryGetLegalWarpGate(currentMap, out _)
|
&& !this.TryGetLegalWarp(currentMap, out _)
|
||||||
&& currentMap.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number;
|
&& currentMap.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number;
|
||||||
var mustEscape = mapIsHostile || mapIsIllegal;
|
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)
|
|| DateTime.UtcNow - this._lastWarpUtc < WarpCooldown)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When escaping, any legal map with something safe to hunt beats staying - and with no legal
|
// When escaping, any legal map with something safe to hunt beats staying. The home retreat is
|
||||||
// warp target at all (a freshly reset veteran may be below every warp requirement), the bot
|
// the last resort AND the safety valve: a bot which cannot afford a single warp - or is below
|
||||||
// retreats to its class home town, like a player using a town scroll.
|
// every warp requirement, as a freshly reset veteran is - would otherwise be stranded on ground
|
||||||
if (!this.TryPickBetterMap(mustEscape, out var targetGate, out var targetMap, out var targetLevel)
|
// that pays it nothing, unable to earn the fare out of it. Walking home is free, like a player
|
||||||
&& !(mustEscape && this.TryGetHomeEscapeGate(out targetGate, out targetMap, out targetLevel)))
|
// 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;
|
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._lastWarpUtc = DateTime.UtcNow;
|
||||||
this._hasDestination = false;
|
this._hasDestination = false;
|
||||||
this._travelPath = null;
|
this._travelPath = null;
|
||||||
this._player.Logger.LogInformation(
|
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,
|
this._player.Name,
|
||||||
botLevel,
|
botLevel,
|
||||||
targetMap.Name,
|
targetMap!.Name,
|
||||||
targetLevel);
|
targetLevel,
|
||||||
await this._player.WarpToAsync(targetGate).ConfigureAwait(false);
|
fare?.Costs ?? 0);
|
||||||
await this.TryPersistCurrentMapAsync(targetMap).ConfigureAwait(false);
|
|
||||||
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,8 +1317,10 @@ internal sealed class BotNavigator : AsyncDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A stack holds as many charges as the item definition allows - no more (see EmergencyPotionCharges).
|
// Only ever up to the emergency amount, never a full stack: a Large Healing Potion holds 255
|
||||||
var stackSize = Math.Max((byte)1, definition.Durability);
|
// 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))
|
foreach (var potion in potions.Where(p => p.Durability < stackSize))
|
||||||
{
|
{
|
||||||
charges += (int)(stackSize - potion.Durability);
|
charges += (int)(stackSize - potion.Durability);
|
||||||
@@ -1322,7 +1433,7 @@ internal sealed class BotNavigator : AsyncDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The bot's reset-aware effective level (see <see cref="BotResetHandler.GetEffectiveLevel"/>),
|
/// 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
|
/// 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.
|
/// and the bots must obey the same rule.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private int GetBotLevel() => BotResetHandler.GetEffectiveLevel(this._player);
|
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="mapDefinition">The target map.</param>
|
||||||
/// <param name="gate">The warp target gate, if a legal entry exists.</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>
|
/// <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)
|
if (this._player.SelectedCharacter is not { } character)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var plainLevel = (int)(this._player.Attributes?[Stats.Level] ?? 1);
|
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
|
.Where(w => w.Gate?.Map?.Number == mapDefinition.Number
|
||||||
&& character.GetEffectiveMoveLevelRequirement(w.LevelRequirement) <= plainLevel)
|
&& character.GetEffectiveMoveLevelRequirement(w.LevelRequirement) <= plainLevel)
|
||||||
.Select(w => w.Gate!)
|
|
||||||
.FirstOrDefault();
|
.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>
|
/// <summary>
|
||||||
/// Gets the escape gate to the bot's class home town, for when it must leave its current map but is
|
/// 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.
|
/// 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 };
|
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>
|
/// <summary>
|
||||||
/// Picks the legally warpable map (other than the current one) that offers the strongest monsters the
|
/// 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.
|
/// 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,
|
/// 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.
|
/// no matter how it compares to the current one.
|
||||||
/// </summary>
|
/// </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
|
// 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
|
// 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
|
// 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.
|
// survive would not earn it master experience either, and its gear still improves meanwhile.
|
||||||
var floor = this.MasterExperienceFloor();
|
var floor = this.MasterExperienceFloor();
|
||||||
return (floor > 0 && this.TryPickBetterMapCore(escape, floor, out gate, out mapDefinition, out monsterLevel))
|
return (floor > 0 && this.TryPickBetterMapCore(escape, floor, out warp, out mapDefinition, out monsterLevel))
|
||||||
|| this.TryPickBetterMapCore(escape, 0, out gate, out mapDefinition, out monsterLevel);
|
|| this.TryPickBetterMapCore(escape, 0, out warp, out mapDefinition, out monsterLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Picks the best map (see <see cref="TryPickBetterMap"/>), counting only monsters of at least
|
/// Picks the best map (see <see cref="TryPickBetterMap"/>), counting only monsters of at least
|
||||||
/// <paramref name="minimumMonsterLevel"/>.
|
/// <paramref name="minimumMonsterLevel"/>.
|
||||||
/// </summary>
|
/// </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!;
|
mapDefinition = default!;
|
||||||
monsterLevel = 0;
|
monsterLevel = 0;
|
||||||
|
|
||||||
var current = this._player.CurrentMap?.Definition;
|
var current = this._player.CurrentMap?.Definition;
|
||||||
var threshold = escape ? 1 : (current is null ? 0 : this.BestSafeLevel(current, minimumMonsterLevel)) + WarpImprovementMargin;
|
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)
|
foreach (var candidate in this._player.GameContext.Configuration.Maps)
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(candidate, current))
|
if (ReferenceEquals(candidate, current))
|
||||||
@@ -1489,16 +1660,48 @@ internal sealed class BotNavigator : AsyncDisposable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.TryGetLegalWarpGate(candidate, out var warpGate))
|
if (this.TryGetLegalWarp(candidate, out var candidateWarp) && this.CanAffordWarp(candidateWarp))
|
||||||
{
|
{
|
||||||
gate = warpGate;
|
candidates.Add((candidateWarp, candidate, best));
|
||||||
mapDefinition = candidate;
|
|
||||||
monsterLevel = best;
|
|
||||||
threshold = best + 1; // keep only a strictly-stronger map after this one
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
/// <summary>
|
||||||
@@ -1580,15 +1783,27 @@ internal sealed class BotNavigator : AsyncDisposable
|
|||||||
|
|
||||||
private bool TryPickWalkablePoint(GameMap map, MonsterSpawnArea area, Point? avoidCenter, out Point point)
|
private bool TryPickWalkablePoint(GameMap map, MonsterSpawnArea area, Point? avoidCenter, out Point point)
|
||||||
{
|
{
|
||||||
var minX = Math.Min(area.X1, area.X2);
|
int minX = Math.Min(area.X1, area.X2);
|
||||||
var maxX = Math.Max(area.X1, area.X2);
|
int maxX = Math.Max(area.X1, area.X2);
|
||||||
var minY = Math.Min(area.Y1, area.Y2);
|
int minY = Math.Min(area.Y1, area.Y2);
|
||||||
var maxY = Math.Max(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++)
|
for (var attempt = 0; attempt < MaxPointPickAttempts; attempt++)
|
||||||
{
|
{
|
||||||
var x = Rand.NextInt(minX, maxX + 1);
|
// GetRandomCoordinate already keeps to the map, retries unwalkable picks and falls back to the
|
||||||
var y = Rand.NextInt(minY, maxY + 1);
|
// 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])
|
if (!map.Terrain.WalkMap[x, y] || map.Terrain.SafezoneMap[x, y])
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -67,6 +67,22 @@ internal static class BotProgression
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly short[] ExcludedBuffSkillNumbers = [18, 219, 221, 222];
|
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>
|
/// <summary>
|
||||||
/// Gets the class the character evolves into at <see cref="ClassEvolutionLevel"/>, or null when the
|
/// Gets the class the character evolves into at <see cref="ClassEvolutionLevel"/>, or null when the
|
||||||
/// class has no (in-scope) evolution.
|
/// class has no (in-scope) evolution.
|
||||||
@@ -294,13 +310,21 @@ internal static class BotProgression
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skill.AttackDamage > 0
|
if (CastleSiegeOnlySkillNumbers.Contains(skill.Number))
|
||||||
&& skill.SkillType is SkillType.DirectHit
|
|
||||||
or SkillType.AreaSkillAutomaticHits
|
|
||||||
or SkillType.AreaSkillExplicitHits
|
|
||||||
or SkillType.AreaSkillExplicitTarget)
|
|
||||||
{
|
{
|
||||||
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
|
return skill.SkillType is SkillType.Buff or SkillType.Regeneration
|
||||||
@@ -308,6 +332,45 @@ internal static class BotProgression
|
|||||||
&& !ExcludedBuffSkillNumbers.Contains(skill.Number);
|
&& !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>
|
/// <summary>
|
||||||
/// Determines whether the character meets the skill's learn requirements (the same ones the game
|
/// 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).
|
/// 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; }
|
public int AccountCount { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a value indicating whether this server generates the bot population. Exactly one server
|
/// Gets a value indicating whether this server generates the bot population - and, likewise, carries
|
||||||
/// does it (the one which animates the first account), so the generation of the accounts - and of
|
/// out the requested reset and purge. Exactly one server does it (the first of the deployment), so
|
||||||
/// their unique character names - never runs twice at the same time. The other servers simply find
|
/// the generation of the accounts - and of their unique character names - never runs twice at the
|
||||||
/// their accounts once they exist; until then, their spawns are retried by the maintenance pass.
|
/// same time. The other servers simply find their accounts once they exist; until then, their spawns
|
||||||
|
/// are retried by the maintenance pass.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsGenerator { get; }
|
public bool IsGenerator { get; }
|
||||||
|
|
||||||
@@ -120,18 +121,27 @@ internal sealed class BotServerPartition
|
|||||||
byte serverId,
|
byte serverId,
|
||||||
int requestedAccounts)
|
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);
|
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)
|
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
|
// 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).
|
// deployment offers the room (another game server, a higher player limit or bot capacity share).
|
||||||
var assignedAccounts = (int)Math.Min(requestedAccounts, totalCapacity);
|
var assignedAccounts = (int)Math.Min(requestedAccounts, totalCapacity);
|
||||||
|
|
||||||
var partition = new BotServerPartition(1, 0, false);
|
var partition = new BotServerPartition(1, 0, isGenerator);
|
||||||
long capacitySoFar = 0;
|
long capacitySoFar = 0;
|
||||||
var accountsSoFar = 0;
|
var accountsSoFar = 0;
|
||||||
foreach (var (currentServer, capacity) in servers)
|
foreach (var (currentServer, capacity) in servers)
|
||||||
@@ -144,8 +154,7 @@ internal sealed class BotServerPartition
|
|||||||
var share = accountsUpToHere - accountsSoFar;
|
var share = accountsUpToHere - accountsSoFar;
|
||||||
if (currentServer == serverId && share > 0)
|
if (currentServer == serverId && share > 0)
|
||||||
{
|
{
|
||||||
// The server which owns the first account generates the population.
|
partition = new BotServerPartition(accountsSoFar + 1, share, isGenerator);
|
||||||
partition = new BotServerPartition(accountsSoFar + 1, share, accountsSoFar == 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
accountsSoFar = accountsUpToHere;
|
accountsSoFar = accountsUpToHere;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
namespace MUnique.OpenMU.GameLogic.Bots;
|
namespace MUnique.OpenMU.GameLogic.Bots;
|
||||||
|
|
||||||
|
using MUnique.OpenMU.DataModel.Entities;
|
||||||
using MUnique.OpenMU.GameLogic.NPC;
|
using MUnique.OpenMU.GameLogic.NPC;
|
||||||
using MUnique.OpenMU.GameLogic.Offline;
|
using MUnique.OpenMU.GameLogic.Offline;
|
||||||
using MUnique.OpenMU.GameLogic.PlayerActions;
|
using MUnique.OpenMU.GameLogic.PlayerActions;
|
||||||
@@ -12,51 +13,48 @@ using MUnique.OpenMU.Pathfinding;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lets a bot trade with town merchants like a real player: when the backpack silts up or the potions
|
/// 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 -
|
/// run low, the bot visits a merchant, sells its junk loot for Zen, repairs its gear and buys refills
|
||||||
/// closing the economic loop (materializing supplies out of thin air stays only as an emergency
|
/// with it - closing the economic loop (materializing supplies out of thin air stays only as an
|
||||||
/// fallback, see the low threshold in <see cref="BotNavigator"/>). The trade uses the regular player
|
/// emergency fallback, see the low threshold in <see cref="BotNavigator"/>). The trade uses the regular
|
||||||
/// actions (<see cref="TalkNpcAction"/>, <see cref="SellItemToNpcAction"/>, <see cref="BuyNpcItemAction"/>),
|
/// player actions (<see cref="TalkNpcAction"/>, <see cref="SellItemToNpcAction"/>,
|
||||||
/// and while the dialog is open the player state pauses the combat AI - the bot visibly "shops".
|
/// <see cref="BuyNpcItemAction"/>, <see cref="ItemRepairAction"/>), and while the dialog is open the
|
||||||
|
/// player state pauses the combat AI - the bot visibly "shops".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class BotShoppingHandler
|
internal static class BotShoppingHandler
|
||||||
{
|
{
|
||||||
/// <summary>Start a shopping trip when fewer free backpack slots than this remain.</summary>
|
/// <summary>Start a shopping trip when fewer free backpack slots than this remain.</summary>
|
||||||
private const int FreeSlotPressure = 20;
|
private const int FreeSlotPressure = 20;
|
||||||
|
|
||||||
/// <summary>Start a shopping trip when a potion kind has fewer charges than this.</summary>
|
/// <summary>Go restocking once a potion kind holds less than this share of the target stock.</summary>
|
||||||
private const int PotionLowThreshold = 40;
|
private const int PotionLowThresholdPercent = 66;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Maximum purchases per potion kind per trip - a safety bound, the stock target is the real limit.</summary>
|
||||||
/// Stop buying refills once this many charges are stocked. Merchants sell potions by the piece,
|
|
||||||
/// so this target keeps a trip affordable (roughly a bot's income between two trips) while lasting
|
|
||||||
/// a good while of hunting.
|
|
||||||
/// </summary>
|
|
||||||
private const int PotionTargetCharges = 60;
|
|
||||||
|
|
||||||
/// <summary>Maximum purchases per potion kind per trip.</summary>
|
|
||||||
private const int MaxPurchasesPerKind = 20;
|
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>
|
/// <summary>Zen the bot keeps in reserve - it stops buying rather than spend its last coin.</summary>
|
||||||
private const int MinZenReserve = 10000;
|
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 TalkNpcAction TalkAction = new();
|
||||||
private static readonly SellItemToNpcAction SellAction = new();
|
private static readonly SellItemToNpcAction SellAction = new();
|
||||||
private static readonly BuyNpcItemAction BuyAction = new();
|
private static readonly BuyNpcItemAction BuyAction = new();
|
||||||
|
private static readonly ItemRepairAction RepairAction = new();
|
||||||
private static readonly CloseNpcDialogAction CloseAction = new();
|
private static readonly CloseNpcDialogAction CloseAction = new();
|
||||||
|
private static readonly ItemPriceCalculator PriceCalculator = new();
|
||||||
private static readonly ItemIdentifier[] Valuables =
|
|
||||||
[
|
|
||||||
ItemConstants.JewelOfChaos,
|
|
||||||
ItemConstants.JewelOfBless,
|
|
||||||
ItemConstants.JewelOfSoul,
|
|
||||||
ItemConstants.JewelOfLife,
|
|
||||||
ItemConstants.JewelOfCreation,
|
|
||||||
ItemConstants.JewelOfGuardian,
|
|
||||||
ItemConstants.Gemstone,
|
|
||||||
ItemConstants.JewelOfHarmony,
|
|
||||||
ItemConstants.LowerRefineStone,
|
|
||||||
ItemConstants.HigherRefineStone,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Determines whether the bot should go shopping: the backpack is filling up with sellable junk,
|
/// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var freeSlots = inventory.FreeSlots.Count();
|
if (IsUnderSlotPressure(inventory) && GetSellableJunk(player, inventory).Count > 0)
|
||||||
if (freeSlots < FreeSlotPressure && inventory.Items.Any(i => IsSellableJunk(player, i)))
|
|
||||||
{
|
{
|
||||||
return true;
|
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 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>
|
/// <summary>
|
||||||
/// Finds the position of a merchant NPC on the map, preferring one which sells potions. The
|
/// Finds the position of a merchant NPC on the map, preferring the one which sells what the bot
|
||||||
/// search runs over the map's LIVE objects, not the spawn configuration: wandering merchants
|
/// needs right now. The search runs over the map's LIVE objects, not the spawn configuration:
|
||||||
/// exist in the configuration but are only spawned now and then (their spawn trigger is not
|
/// wandering merchants exist in the configuration but are only spawned now and then (their spawn
|
||||||
/// automatic) - a bot walking to a configured but unspawned merchant would wait at an empty
|
/// trigger is not automatic) - a bot walking to a configured but unspawned merchant would wait at an
|
||||||
/// spot and give up its trip, forever.
|
/// empty spot and give up its trip, forever.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="player">The bot player, whose current needs rank the merchants.</param>
|
||||||
/// <param name="map">The game map.</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.
|
// Covers the whole 256x256 map from its center.
|
||||||
var merchants = map.GetNpcsInRange(new Point(128, 128), 256)
|
var merchants = map.GetNpcsInRange(new Point(128, 128), 256)
|
||||||
.Where(n => n.Definition is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore.Items.Count: > 0 })
|
.Where(n => n.Definition is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore.Items.Count: > 0 })
|
||||||
.ToList();
|
.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;
|
return best?.Position;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performs the actual trade with the merchant standing near the given position: opens the dialog,
|
/// 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>
|
/// </summary>
|
||||||
/// <param name="player">The bot player.</param>
|
/// <param name="player">The bot player.</param>
|
||||||
/// <param name="map">The game map.</param>
|
/// <param name="map">The game map.</param>
|
||||||
@@ -131,46 +153,35 @@ internal static class BotShoppingHandler
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var soldCount = 0;
|
// Repairing first is not cosmetic: it is the one purchase the bot always makes, and for a
|
||||||
foreach (var junk in inventory.Items.Where(i => IsSellableJunk(player, i)).ToList())
|
// 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,
|
||||||
await SellAction.SellItemAsync(player, junk.ItemSlot).ConfigureAwait(false);
|
// while the repair a moment later freed enough room to have sold a good part of it.
|
||||||
soldCount++;
|
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;
|
var store = player.OpenedNpc.Definition.MerchantStore;
|
||||||
foreach (var potionNumber in GetLowPotionKinds(player))
|
var boughtPotions = store is null ? 0 : await BuyPotionsAsync(player, store).ConfigureAwait(false);
|
||||||
{
|
var boughtJewels = store is null ? 0 : await BuyJewelsAsync(player, store).ConfigureAwait(false);
|
||||||
var storeItem = store?.Items.FirstOrDefault(i => i.Definition?.Group == 14 && i.Definition.Number == potionNumber);
|
|
||||||
if (storeItem is null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < MaxPurchasesPerKind
|
// Only now, with every purchase of this visit paid for, is it settled how much room the
|
||||||
&& GetPotionCharges(player, potionNumber) < PotionTargetCharges
|
// money limit really leaves: each Zen spent above buys back the chance to sell one more
|
||||||
&& player.Money > MinZenReserve; i++)
|
// piece instead of destroying it.
|
||||||
{
|
var (soldLate, discarded) = await ClearUnsoldAsync(player, inventory, unsold).ConfigureAwait(false);
|
||||||
var moneyBefore = player.Money;
|
sold += soldLate;
|
||||||
await BuyAction.BuyItemAsync(player, storeItem.ItemSlot).ConfigureAwait(false);
|
|
||||||
if (player.Money >= moneyBefore)
|
|
||||||
{
|
|
||||||
break; // purchase failed (no money / no space)
|
|
||||||
}
|
|
||||||
|
|
||||||
boughtCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logged even for a 0/0 visit: an audit must be able to tell "went and had nothing to
|
// 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(
|
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,
|
player.Name,
|
||||||
merchant.Definition.Designation,
|
merchant.Definition.Designation,
|
||||||
soldCount,
|
sold,
|
||||||
boughtCount,
|
discarded,
|
||||||
|
repaired,
|
||||||
|
boughtPotions,
|
||||||
|
boughtJewels,
|
||||||
player.Money);
|
player.Money);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -182,64 +193,363 @@ internal static class BotShoppingHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </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
|
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);
|
.Sum(i => i.Durability) ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sellable junk: unequipped gear in the backpack - not potions/ammunition and not jewels. An
|
/// Finds the best offer for a potion list: the highest priority kind the merchant has and the bot can
|
||||||
/// excellent or ancient piece is junk too unless it is an upgrade the bot is about to wear:
|
/// afford, and of that kind the biggest stack - one purchase of a 255 charge stack beats twenty
|
||||||
/// unlike a player, a bot cannot trade its treasures away, so hoarding them only silts up the
|
/// purchases of a stack of three, in Zen spent per charge as well as in backpack slots used.
|
||||||
/// backpack until the loot pickup stops entirely.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool IsSellableJunk(OfflinePlayer player, Item item)
|
private static Item? FindBestOffer(Player player, ItemStorage store, ItemIdentifier[] priority)
|
||||||
{
|
{
|
||||||
if (item.ItemSlot < InventoryConstants.EquippableSlotsCount
|
foreach (var identifier in priority)
|
||||||
|| item.Definition is not { } definition)
|
{
|
||||||
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (definition.Group >= 12 || definition.IsAmmunition)
|
var identifier = new ItemIdentifier(definition.Number, definition.Group);
|
||||||
{
|
return BotJewelHandler.UsableJewels.Contains(identifier)
|
||||||
return false; // potions, jewels, scrolls, event items etc.
|
|| BotJewelHandler.UnusableJewels.Contains(identifier);
|
||||||
}
|
|
||||||
|
|
||||||
if (Valuables.Contains(new ItemIdentifier(definition.Number, definition.Group)))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var isTreasure = item.ItemOptions.Any(o => o.ItemOption?.OptionType == DataModel.Configuration.Items.ItemOptionTypes.Excellent)
|
|
||||||
|| item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0);
|
|
||||||
if (isTreasure)
|
|
||||||
{
|
|
||||||
return !BotEquipmentHandler.IsUpgradeFor(player, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool SellsPotions(DataModel.Entities.ItemStorage store)
|
private static bool SellsPotions(ItemStorage store)
|
||||||
{
|
=> HealingHandler.HealthPotionPriority.Concat(HealingHandler.ManaPotionPriority)
|
||||||
return store.Items.Any(i => i.Definition?.Group == 14 && i.Definition.Number is 3 or 6);
|
.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();
|
private static readonly BotSkillProgressionPlugIn CatchUp = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies the progression a bot is owed but has not spent, when it enters the world. Points are
|
/// Applies the progression a bot is owed but has not received, when it enters the world. Both stat
|
||||||
/// otherwise only invested on a level-up, so a character which was given points while it was not
|
/// points and skills are otherwise only handed out on a level-up, so anything a bot became entitled
|
||||||
/// playing - a freshly generated one, or one whose level-up handler failed - would carry them around
|
/// to while it was not playing waits for its next one - and a bot at the maximum level has no next
|
||||||
/// unspent until its next level-up and fight with the strength of a much weaker character in the
|
/// one. That covers a freshly generated character, one whose level-up handler failed, and a bot which
|
||||||
/// meantime. Cheap: only a bot which actually holds points is progressed.
|
/// qualifies for a skill it was not allowed to learn when it last levelled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="player">The bot which entered the world.</param>
|
/// <param name="player">The bot which entered the world.</param>
|
||||||
public static void CatchUpPendingProgress(Player player)
|
public static void CatchUpPendingProgress(Player player)
|
||||||
{
|
{
|
||||||
if (player.SelectedCharacter?.LevelUpPoints > 0)
|
CatchUp.CharacterLeveledUp(player);
|
||||||
{
|
|
||||||
CatchUp.CharacterLeveledUp(player);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
namespace MUnique.OpenMU.GameLogic.Offline;
|
namespace MUnique.OpenMU.GameLogic.Offline;
|
||||||
|
|
||||||
using MUnique.OpenMU.DataModel.Entities;
|
using MUnique.OpenMU.DataModel.Entities;
|
||||||
|
using MUnique.OpenMU.GameLogic.Bots;
|
||||||
using MUnique.OpenMU.GameLogic.MuHelper;
|
using MUnique.OpenMU.GameLogic.MuHelper;
|
||||||
using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
|
using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
|
||||||
using MUnique.OpenMU.GameLogic.PlugIns;
|
using MUnique.OpenMU.GameLogic.PlugIns;
|
||||||
@@ -114,7 +115,8 @@ public sealed class BuffHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
var skillEntry = this._player.SkillList?.GetSkill((ushort)buffId);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -132,6 +134,22 @@ public sealed class BuffHandler
|
|||||||
return true;
|
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>
|
/// <summary>
|
||||||
/// Attempts to apply the buff to self and, if applicable, to party members.
|
/// Attempts to apply the buff to self and, if applicable, to party members.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ public sealed class CombatHandler
|
|||||||
private const byte DefaultRange = 1;
|
private const byte DefaultRange = 1;
|
||||||
private const byte BowRange = 6;
|
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>
|
/// <summary>
|
||||||
/// See <see cref="IsSafeTarget"/>: the largest share of the bot's maximum health a single average
|
/// 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
|
/// 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>
|
/// <summary>After this many consecutive failed approaches the target counts as unreachable.</summary>
|
||||||
private const int MaxApproachFailures = 3;
|
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 DrainLifeBaseSkillId = 214;
|
||||||
private const short DrainLifeStrengthenerSkillId = 458;
|
private const short DrainLifeStrengthenerSkillId = 458;
|
||||||
private const short DrainLifeMasterySkillId = 462;
|
private const short DrainLifeMasterySkillId = 462;
|
||||||
@@ -90,6 +113,7 @@ public sealed class CombatHandler
|
|||||||
|
|
||||||
private IAttackable? _currentTarget;
|
private IAttackable? _currentTarget;
|
||||||
private int _nearbyMonsterCount;
|
private int _nearbyMonsterCount;
|
||||||
|
private int _targetsAroundCurrent = 1;
|
||||||
private int _currentComboStep;
|
private int _currentComboStep;
|
||||||
private int _skillCooldownTicks;
|
private int _skillCooldownTicks;
|
||||||
private int _approachFailures;
|
private int _approachFailures;
|
||||||
@@ -97,6 +121,7 @@ public sealed class CombatHandler
|
|||||||
private DateTime _unreachableTargetUntilUtc = DateTime.MinValue;
|
private DateTime _unreachableTargetUntilUtc = DateTime.MinValue;
|
||||||
private SkillEntry? _tickBestSkill;
|
private SkillEntry? _tickBestSkill;
|
||||||
private bool _tickBestSkillComputed;
|
private bool _tickBestSkillComputed;
|
||||||
|
|
||||||
private DateTime _engageAtUtc = DateTime.MinValue;
|
private DateTime _engageAtUtc = DateTime.MinValue;
|
||||||
|
|
||||||
/// <summary>
|
/// <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
|
/// 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".
|
/// apart "kills this monster at a reasonable pace" from "barely scratches it".
|
||||||
/// </summary>
|
/// </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)
|
private static float GetAttackPower(Player player)
|
||||||
{
|
{
|
||||||
if (player.Attributes is not { } attributes)
|
if (player.Attributes is not { } attributes)
|
||||||
@@ -418,6 +458,7 @@ public sealed class CombatHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._player.Rotation = this._player.GetDirectionTo(target);
|
this._player.Rotation = this._player.GetDirectionTo(target);
|
||||||
|
this._player.LastAttackUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
if (skillEntry?.Skill is not { } skill)
|
if (skillEntry?.Skill is not { } skill)
|
||||||
{
|
{
|
||||||
@@ -478,10 +519,13 @@ public sealed class CombatHandler
|
|||||||
// character's hunting efficiency, not a crowd to camouflage.
|
// character's hunting efficiency, not a crowd to camouflage.
|
||||||
this._currentTarget = candidates.SelectRandom();
|
this._currentTarget = candidates.SelectRandom();
|
||||||
this._nearbyMonsterCount = targets.Count;
|
this._nearbyMonsterCount = targets.Count;
|
||||||
|
this._targetsAroundCurrent = CountPackAround(this._currentTarget, targets);
|
||||||
}
|
}
|
||||||
else
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
SkillEntry? best = null;
|
var ridesFenrir = this.RidesFenrir();
|
||||||
var bestDamage = 0;
|
var candidates = new List<(SkillEntry Entry, float Score)>();
|
||||||
foreach (var entry in skillList.Skills)
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skill.SkillType is not (SkillType.DirectHit
|
candidates.Add((entry, this.ScoreSkill(skill)));
|
||||||
or SkillType.AreaSkillAutomaticHits
|
|
||||||
or SkillType.AreaSkillExplicitHits
|
|
||||||
or SkillType.AreaSkillExplicitTarget))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skill.AttackDamage > bestDamage && this.HasEnoughResources(entry))
|
|
||||||
{
|
|
||||||
best = entry;
|
|
||||||
bestDamage = skill.AttackDamage;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this._tickBestSkill = best;
|
if (candidates.Count == 0)
|
||||||
return best;
|
{
|
||||||
|
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>
|
/// <summary>
|
||||||
/// Evaluates whether the skill in the given slot should fire this tick.
|
/// Evaluates whether the skill in the given slot should fire this tick.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ using MUnique.OpenMU.Interfaces;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HealingHandler
|
public sealed class HealingHandler
|
||||||
{
|
{
|
||||||
/// <summary>Drink a mana potion once mana falls below this share, so casters can keep casting.</summary>
|
/// <summary>
|
||||||
private const int ManaThresholdPercent = 30;
|
/// 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,
|
||||||
private static readonly ItemConsumeAction ConsumeAction = new();
|
/// is how a bot ends up starving next to a full merchant.
|
||||||
|
/// </summary>
|
||||||
private static readonly ItemIdentifier[] HealthPotionPriority =
|
internal static readonly ItemIdentifier[] HealthPotionPriority =
|
||||||
[
|
[
|
||||||
ItemConstants.LargeHealingPotion,
|
ItemConstants.LargeHealingPotion,
|
||||||
ItemConstants.MediumHealingPotion,
|
ItemConstants.MediumHealingPotion,
|
||||||
@@ -31,13 +31,21 @@ public sealed class HealingHandler
|
|||||||
ItemConstants.Apple,
|
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.LargeManaPotion,
|
||||||
ItemConstants.MediumManaPotion,
|
ItemConstants.MediumManaPotion,
|
||||||
ItemConstants.SmallManaPotion,
|
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 OfflinePlayer _player;
|
||||||
private readonly IMuHelperSettings? _config;
|
private readonly IMuHelperSettings? _config;
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,11 @@ public sealed class ItemPickupHandler
|
|||||||
|
|
||||||
if (this._config.PickJewel && IsJewel(item))
|
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);
|
var isAncient = item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0);
|
||||||
|
|||||||
@@ -157,6 +157,15 @@ public class OfflinePlayer : Player
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal bool HasRevengeIntent => this._revenge is not null;
|
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>
|
/// <summary>
|
||||||
/// Initializes the offline player by loading the account fresh from the database.
|
/// Initializes the offline player by loading the account fresh from the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ public class SellItemToNpcAction
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="player">The player.</param>
|
/// <param name="player">The player.</param>
|
||||||
/// <param name="slot">The slot.</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());
|
using var loggerScope = player.Logger.BeginScope(this.GetType());
|
||||||
var item = player.Inventory?.GetItem(slot);
|
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);
|
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);
|
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(false)).ConfigureAwait(false);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (player.OpenedNpc?.Definition.MerchantStore is null)
|
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);
|
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);
|
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)))
|
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);
|
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());
|
var sellingPrice = (int)this._itemPriceCalculator.CalculateSellingPrice(item, item.Durability());
|
||||||
player.Logger.LogDebug("Calculated selling price {0} for item {1}", sellingPrice, item);
|
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);
|
// The money doesn't fit into the inventory anymore. Without the answer the request would
|
||||||
await player.Inventory!.RemoveItemAsync(item).ConfigureAwait(false);
|
// stay unanswered - the client keeps waiting, and the player gets no hint why nothing
|
||||||
await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false);
|
// happened. All other refusals above already report back this way.
|
||||||
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(true)).ConfigureAwait(false);
|
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);
|
||||||
player.GameContext.PlugInManager.GetPlugInPoint<IItemSoldToMerchantPlugIn>()?.ItemSold(player, item, player.OpenedNpc!);
|
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);
|
var bless = await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfBless).ConfigureAwait(false);
|
||||||
await AddJewelAsync(player, FirstBackpackSlot + 1, 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, Is.Not.Null);
|
||||||
Assert.That(plan!.Value.Jewel, Is.SameAs(bless));
|
Assert.That(plan!.Value.Jewel, Is.SameAs(bless));
|
||||||
Assert.That(plan.Value.Target, Is.SameAs(weakPiece));
|
Assert.That(plan.Value.Target, Is.SameAs(weakPiece));
|
||||||
Assert.That(plan.Value.IsLife, Is.False);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -59,7 +58,7 @@ public class BotJewelHandlerTest
|
|||||||
await AddJewelAsync(player, (byte)(FirstBackpackSlot + i), ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
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));
|
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, ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||||
await AddJewelAsync(player, FirstBackpackSlot + 1, 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, Is.Not.Null);
|
||||||
Assert.That(plan!.Value.Target, Is.SameAs(luckyPiece));
|
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, ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||||
await AddJewelAsync(player, FirstBackpackSlot + 1, 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));
|
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
|
||||||
}
|
}
|
||||||
@@ -116,19 +115,43 @@ public class BotJewelHandlerTest
|
|||||||
{
|
{
|
||||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||||
var target = await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 9).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);
|
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));
|
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
|
||||||
if (expectsUse)
|
if (expectsUse)
|
||||||
{
|
{
|
||||||
Assert.That(plan!.Value.Target, Is.SameAs(target));
|
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>
|
/// <summary>
|
||||||
/// Without any applicable jewel or target, nothing is planned.
|
/// Without any applicable jewel or target, nothing is planned.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -138,7 +161,7 @@ public class BotJewelHandlerTest
|
|||||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||||
await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 2).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);
|
Assert.That(plan, Is.Null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,38 @@ public class BotServerPartitionTest
|
|||||||
Assert.That(BotServerPartition.Split(capacities, 2, 150).Partition.IsGenerator, Is.False);
|
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>
|
/// <summary>
|
||||||
/// The servers may have different player limits; the shares follow their capacity.
|
/// The servers may have different player limits; the shares follow their capacity.
|
||||||
/// </summary>
|
/// </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