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:
sven-n
2026-07-22 21:56:51 +02:00
committed by Acentech Dev
parent eb4c05eda2
commit e910845383
21 changed files with 1691 additions and 287 deletions

View File

@@ -65,8 +65,21 @@ The *Bots* feature plugin, in the "Feature Plugins" section of the admin panel:
- **`Bots pay reset costs`** — whether bots pay the configured zen and item
costs for their resets. Off by default: they take no part in the economy those
costs are balanced for.
- **`Reset bots`** — purges and regenerates the whole population on the next
start, then clears itself.
- **`Jewel stock per kind`** — how many Jewels of Bless, Soul and Life a bot
keeps of each kind. Above it, it stops picking them up and sells what it
already carries. Depends on the server's drop rates: on high rates a small
stock keeps the backpack free, on low rates a larger one is never reached
anyway.
- **`Potion stock charges`** — how many charges of healing and of mana potions a
bot restocks to at a merchant. Depends on what the shops sell: a server whose
potions come in stacks of 255 fills the target in a single purchase.
- **`Reset bots`** — deletes the whole population and generates it again, then
clears itself. With the number of accounts set to zero it deletes without
generating anything.
- **`Purge bots`** — deletes the whole population WITHOUT generating a new one,
and switches the feature off. It works with the feature enabled or disabled;
the switch-off is what makes it a purge, since the same pass would otherwise
create the population again right after deleting it. Clears itself afterwards.
## What a bot does
@@ -83,19 +96,52 @@ chance to be hit — a monster's nominal level says little about its punch on th
high-end maps. An agility build's dodge therefore counts as the defense it
really is, and better gear opens tougher maps, exactly like for a player.
Map access follows the game's warp list: a bot enters a map only if its level
may legally warp there, and a bot which finds itself on a map it may not be on
(after a reset, for instance) leaves for the best map it may use. The map it
reached is persisted, so a restarted bot wakes up where it stopped.
Map access follows the game's warp list, and a bot travels on a player's terms:
it enters a map only if its level may legally warp there, it meets the map's own
requirements, and it pays the warp's fare out of its own Zen. A bot which finds
itself on a map it may not be on (after a reset, for instance) leaves for the
best map it may use. The map it reached is persisted, so a restarted bot wakes up
where it stopped.
Which map it goes to is drawn rather than maximized. The maps of a level band
differ by a few monster levels, so always taking the strongest one made it every
bot's answer and left the rest of the band deserted. The best map still wins
about a third of the picks and the runners-up split the rest, so the population
spreads over the maps a player of that band would choose between. Every map in
the draw is one the bot may legally reach, can afford, and which is better than
where it stands - the draw only decides between improvements.
A map can pass every check and still pay nothing: the monsters the bot may fight
are a rare kind among ones it must refuse, or other hunters empty the grounds
first. The bot notices the way a player would, by having landed no hit in
minutes, and steps down to easier ground one notch at a time until it finds
something it can farm; the regular map choice carries it back up as its level and
gear recover. A bot which cannot afford any trip at all walks home to its class
town for free, so it is never stranded on ground it cannot earn on.
### Fighting and progressing
A bot fights with the strongest skill of its class it has learned and can pay
for; casters keep their distance and drink mana. Skills are learned against the
for; casters keep their distance and drink mana. Between skills worth about the
same it takes the one with the longer reach - the flat bonus of a spell is a
rounding error next to a high-level character's own damage, while three tiles of
range are three tiles at any level. Skills the game only activates during a
castle siege are left out, and so are a pet's skills unless the pet is actually
equipped: Plasma Storm draws its damage from the Fenrir, but the attribute behind
it is derived from the character's own stats, so nothing but the pet slot tells a
mounted character from one riding nothing. Skills are learned against the
game's own requirements — total energy, leadership, character level — at
generation and again on every level-up, and the class buffs are kept up on their
own.
A skill the character cannot currently cast is passed over, in the attack
rotation and in the buffs alike. That is not the same as not having learned it: a
reset keeps every skill but takes back the level which unlocked it, so a veteran
back at level 12 still owns Swell Life, which asks for level 120. The game
refuses such a cast silently, so a character which kept trying would simply stand
there — buffing something that never takes effect, and never getting as far as
attacking.
Level-up points follow a per-class build modelled on what players actually play:
an agility/shield meta on reset servers, guide-style builds on classic ones,
chosen automatically by whether the reset feature is configured. Classes with
@@ -136,13 +182,30 @@ weapon. If the engine refuses the equip after all, the old gear goes straight
back on. The replaced piece stays in the backpack and is sold on the next trip
to town, rather than being dropped where the next bot would pick it up again.
When the backpack fills up or the potions run low, the bot walks to a merchant,
sells its junk, and buys refills from the proceeds while the shop dialog visibly
occupies it — on a map without a merchant it warps home first, like a player
would. Looted Jewels of Bless, Soul and Life are spent on its own equipment
through the regular consume action, with the same success rates and failure
penalties a player faces, and with the caution a player shows: a Soul is only
risked where a failure cannot destroy the item's level.
A merchant trip is the only moment a bot can turn loot into anything, so it goes
whenever it has something to gain there: the backpack is filling with junk, the
potions are running low, a jewel is waiting to be used, or a surplus is waiting
to be sold. Restocking needs the means to pay for it, though - Zen, or loot to
sell once it is there. A broke bot buys nothing, so the trip would leave it just
as short as it set out, and it would set out again instead of hunting, which is
the only way it could have earned the money. It picks the merchant which sells what it needs right now, and on a
map whose merchants sell no potions while it needs some, it warps home to a real
town instead. While the shop dialog visibly occupies it, the bot sells its junk,
repairs its gear — which is what earns the NPC's discount — and buys potions and,
where a shop offers them, jewels.
Only Jewels of Bless, Soul and Life are collected, and only up to the configured
stock: a bot cannot trade or craft, so any other kind would be a backpack slot it
never gets back. They are spent on its own equipment through the regular consume
action, with the same success rates and failure penalties a player faces, and
with the caution a player shows: a Soul is only risked where a failure cannot
destroy the item's level.
A bot which reaches the server's maximum inventory money can no longer sell
anything — the money simply does not fit. What it cannot sell it keeps, and
destroys only what has no other way out: jewels beyond its stock, and, while the
backpack is genuinely full, junk gear. The repair bill is what normally keeps it
away from that limit in the first place.
Wings do not drop, so bots earn them at the classic milestones instead — the
first pair at level 180, the second at 280 and the third, master-only pair at
@@ -253,5 +316,10 @@ hash dominates); starting an existing one of 1100 bots takes some 15 seconds.
game server, over which the population then spreads by itself.
3. Restart the server. The population is generated on the first start and
reloaded afterwards.
4. To build a fresh population, set `Reset bots`: it purges the old one,
4. To build a fresh population, set `Reset bots`: it deletes the old one,
generates a new one, and clears the flag again.
5. To stop the bots without losing them, uncheck `Enabled`: they log out within
a few seconds and nothing is deleted, so checking it again brings the same
characters back. To get rid of them for good, set `Purge bots` — it deletes
every bot account with its characters, items and storages, and leaves the
feature switched off.

View File

@@ -31,6 +31,15 @@ public class BotConfiguration
[Display(Name = "Reset bots", Description = "Deletes all bot accounts and characters on the next start, then regenerates them. Clears itself afterwards.")]
public bool ResetBots { get; set; }
/// <summary>
/// Gets or sets a value indicating whether all bot accounts and characters should be deleted
/// WITHOUT being regenerated. Unlike <see cref="ResetBots"/> this also turns <see cref="Enabled"/>
/// off - otherwise the very same pass would generate the population again - so it is the single
/// switch for "I do not want bots on this server anymore". Clears itself afterwards.
/// </summary>
[Display(Name = "Purge bots", Description = "Deletes all bot accounts and characters and turns the bot feature off, without generating new ones. Clears itself afterwards.")]
public bool PurgeBots { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the bot population rotates its presence over the day:
/// fewer bots are online at night, most in the evening, with bots smoothly logging in and out -
@@ -83,6 +92,26 @@ public class BotConfiguration
[Display(Name = "Bots pay reset costs", Description = "If enabled, bots consume the configured zen/item costs for their resets like human players (default: free bot resets).")]
public bool BotsPayResetCosts { get; set; }
/// <summary>
/// Gets or sets how many Jewels of Bless, Soul and Life a bot keeps of each kind. Bots only pick up
/// the jewels they can actually spend on their own gear (see <c>BotJewelHandler</c>) and stop
/// collecting a kind once they hold this many; whatever they carry above it is sold on the next
/// merchant visit. The sensible value depends entirely on the server's drop rates - on a high rate
/// server a bot refills a big stock within hours, so a low limit keeps its backpack usable.
/// </summary>
[Display(Name = "Jewel stock per kind", Description = "How many Jewels of Bless/Soul/Life a bot keeps of each kind; above this it stops picking them up and sells the surplus.")]
[Range(0, 100)]
public int JewelStockPerKind { get; set; } = 10;
/// <summary>
/// Gets or sets the number of potion charges (per healing and per mana potions) a bot stocks up to
/// at a merchant. Merchants sell potions in stacks of different sizes, so this is the target the bot
/// buys towards, not a stack count.
/// </summary>
[Display(Name = "Potion stock (charges)", Description = "How many healing and mana potion charges a bot buys up to at a merchant.")]
[Range(10, 255)]
public int PotionStockCharges { get; set; } = 60;
/// <summary>
/// Gets or sets a comma separated list of login names of existing accounts to animate as bots.
/// This is an optional extra hook alongside the generated population (see
@@ -109,6 +138,22 @@ public class BotConfiguration
public int GetEffectiveBotCapacityPercent()
=> Math.Clamp(this.BotCapacityPercent, 1, 100);
/// <summary>
/// Gets the effective, clamped jewel stock a bot keeps of each usable kind.
/// </summary>
/// <returns>A value between 0 and 100.</returns>
/// <remarks>Deliberately a method, like <see cref="GetEffectiveCharactersPerAccount"/>.</remarks>
public int GetEffectiveJewelStockPerKind()
=> Math.Clamp(this.JewelStockPerKind, 0, 100);
/// <summary>
/// Gets the effective, clamped potion charges a bot stocks up to.
/// </summary>
/// <returns>A value between 10 and 255.</returns>
/// <remarks>Deliberately a method, like <see cref="GetEffectiveCharactersPerAccount"/>.</remarks>
public int GetEffectivePotionStockCharges()
=> Math.Clamp(this.PotionStockCharges, 10, 255);
/// <summary>
/// Parses <see cref="ProofOfConceptAccounts"/> into the distinct, trimmed login names.
/// </summary>

View File

@@ -71,10 +71,37 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus
/// <inheritdoc />
public BotConfiguration? Configuration { get; set; }
/// <summary>
/// Gets the bot configuration of the given game context, so the bot handlers can read their
/// admin-panel editable settings without being handed the plugin around (same shape as
/// <see cref="BotResetHandler.GetResetConfiguration"/>).
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <returns>The configuration, or <c>null</c> when the bot feature is not configured.</returns>
public static BotConfiguration? GetConfiguration(IGameContext gameContext)
=> gameContext.FeaturePlugIns.GetPlugIn<BotFeaturePlugIn>()?.Configuration;
/// <inheritdoc />
public async ValueTask ExecuteTaskAsync(GameContext gameContext)
{
var state = this._states.GetOrAdd(gameContext, _ => new ServerState());
var configuration = this.Configuration ??= CreateDefaultConfiguration();
if (configuration.PurgeBots)
{
await this.PurgeAsync(gameContext, state, configuration).ConfigureAwait(false);
return;
}
if (!configuration.Enabled)
{
// Switching the feature off takes effect right away instead of at the next restart: the
// bots log out, and nothing is deleted. Re-checked on the following ticks, the feature may
// get enabled again later - then the population is spawned from scratch.
await this.StopBotsAsync(gameContext, state, "the bot feature was switched off").ConfigureAwait(false);
return;
}
if (state.StartupState == (int)StartupPhase.Done)
{
await this.RunMaintenanceAsync(gameContext, state).ConfigureAwait(false);
@@ -87,14 +114,6 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus
return;
}
var configuration = this.Configuration ??= CreateDefaultConfiguration();
if (!configuration.Enabled)
{
// Not spawned - re-check on the following ticks, the feature may get enabled later.
Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.NotStarted);
return;
}
try
{
await this.SpawnPopulationAsync(gameContext, state, configuration).ConfigureAwait(false);
@@ -107,6 +126,114 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus
}
}
/// <summary>
/// Stops every bot this server animates and puts it back to the state before the startup pass, so
/// the population is spawned from scratch if the feature is switched on again. Nothing is deleted.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="state">The state of this server.</param>
/// <param name="reason">The reason to log, if there was anything to stop.</param>
private async ValueTask StopBotsAsync(GameContext gameContext, ServerState state, string reason)
{
if (state.Manager.BotCount == 0 && state.StartupState == (int)StartupPhase.NotStarted)
{
// The usual case of a server without bots - this runs on every tick, so it stays cheap.
return;
}
// Take over the startup state machine, so the bots are not stopped while a startup pass is
// still spawning them (it would spawn into the emptied manager afterwards).
if (Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.Done) != (int)StartupPhase.Done
&& Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.NotStarted) != (int)StartupPhase.NotStarted)
{
// A startup pass is running; retried on one of the next ticks.
return;
}
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
using var scope = logger.BeginScope(gameContext);
try
{
var stopped = state.Manager.BotCount;
await state.Manager.StopAllAsync().ConfigureAwait(false);
state.PendingRespawns.Clear();
if (stopped > 0)
{
logger.LogInformation("Stopped {Stopped} bot(s): {Reason}.", stopped, reason);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to stop the bots.");
}
finally
{
Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.NotStarted);
}
}
/// <summary>
/// Carries out a requested purge: every bot account is deleted and the feature switches itself off,
/// so the population is NOT generated again - the difference to <see cref="BotConfiguration.ResetBots"/>.
/// Works with the feature enabled or disabled, and whatever number of accounts is configured.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="state">The state of this server.</param>
/// <param name="configuration">The bot configuration.</param>
private async ValueTask PurgeAsync(GameContext gameContext, ServerState state, BotConfiguration configuration)
{
if (DateTime.UtcNow < state.NextRunUtc)
{
// The same head start the startup pass gets - and where a failed purge backs off to.
return;
}
// The bots have to be gone before their accounts are: one which is still online would go on
// saving a character whose row is about to be deleted.
await this.StopBotsAsync(gameContext, state, "the bot population is being purged").ConfigureAwait(false);
if (state.Manager.BotCount > 0
|| Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.NotStarted) != (int)StartupPhase.NotStarted)
{
// A startup pass still holds the state machine; retried on one of the next ticks.
return;
}
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
using var scope = logger.BeginScope(gameContext);
try
{
var partition = await BotServerPartition.CreateAsync(gameContext, configuration, logger).ConfigureAwait(false);
if (!partition.IsGenerator)
{
// Another server deletes the accounts and clears the flags. This one only had to stop
// its own bots; the switched-off feature keeps it from starting them again.
return;
}
var generator = new BotGenerator(gameContext, logger);
var deleted = await generator.DeleteAllBotsAsync().ConfigureAwait(false);
// Switching the feature off is what makes this a purge instead of a reset: the generation
// below would otherwise create the whole population again, right after it was deleted.
configuration.Enabled = false;
configuration.PurgeBots = false;
configuration.ResetBots = false;
await this.PersistConfigurationAsync(gameContext, configuration, logger).ConfigureAwait(false);
logger.LogInformation("Purge requested: deleted {Deleted} bot account(s) and switched the bot feature off.", deleted);
}
catch (Exception ex)
{
// The flag stays set, so the purge is retried - backed off, to not repeat it every second.
state.NextRunUtc = DateTime.UtcNow + MaintenanceInterval;
logger.LogError(ex, "Failed to purge the bot population.");
}
finally
{
Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.NotStarted);
}
}
private async ValueTask SpawnPopulationAsync(GameContext gameContext, ServerState state, BotConfiguration configuration)
{
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
@@ -115,12 +242,18 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus
var generator = new BotGenerator(gameContext, logger);
var partition = state.Partition = await BotServerPartition.CreateAsync(gameContext, configuration, logger).ConfigureAwait(false);
if (configuration.ResetBots && !partition.IsGenerator)
{
// Not silent: the flag is set, but this server is not the one which acts on it.
logger.LogInformation("Reset requested: another game server of the deployment carries it out.");
}
if (configuration.ResetBots && partition.IsGenerator)
{
try
{
var deleted = await generator.DeleteAllBotsAsync().ConfigureAwait(false);
logger.LogInformation("Reset requested: purged {Deleted} bot account(s).", deleted);
logger.LogInformation("Reset requested: deleted {Deleted} bot account(s); {Requested} account(s) are generated again.", deleted, Math.Max(configuration.NumberOfAccounts, 0));
// Clear the flag (in memory and persisted) so the next restart does not purge again.
configuration.ResetBots = false;

View File

@@ -184,14 +184,17 @@ internal sealed class BotGenerator
}
/// <summary>
/// Deletes all bot accounts (and, by cascade, their characters and owned data).
/// Deletes all bot accounts with their characters, item storages and items.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of deleted bot accounts.</returns>
public async ValueTask<int> DeleteAllBotsAsync(CancellationToken cancellationToken = default)
{
using var context = this._gameContext.PersistenceContextProvider.CreateNewPlayerContext(this._gameContext.Configuration);
var deleted = 0;
// Collect first, delete afterwards: the paging query orders by login name, so deleting while
// paging would shift the accounts which are not visited yet into the pages already passed.
var loginNames = new List<string>();
const int pageSize = 100;
var skip = 0;
while (true)
@@ -203,31 +206,51 @@ internal sealed class BotGenerator
break;
}
var bots = page.Where(a => a.IsBot).ToList();
var removed = 0;
foreach (var bot in bots)
loginNames.AddRange(page.Where(account => account.IsBot).Select(account => account.LoginName));
skip += page.Count;
}
var deleted = 0;
foreach (var loginName in loginNames)
{
cancellationToken.ThrowIfCancellationRequested();
// Load the account again, this time with its whole graph: the paging query returns the
// accounts untracked and without their characters, and deleting such a shallow account
// leaves its item storages behind. A character's inventory is referenced BY the character,
// so no delete cascade ever reaches it - those storages, and every item lying in them, would
// stay in the database forever as unreachable rows.
var account = await context.GetAccountByLoginNameAsync(loginName, cancellationToken).ConfigureAwait(false);
if (account is null)
{
if (await context.DeleteAsync(bot).ConfigureAwait(false))
continue;
}
foreach (var character in account.Characters)
{
if (character.Inventory is { } inventory)
{
deleted++;
removed++;
}
else
{
// Not silent: a bot account which survives the reset is spawned again right after
// it, and the paging below would never look at it a second time.
this._logger.LogWarning("Bot account '{LoginName}' could not be deleted for the bot reset.", bot.LoginName);
await context.DeleteAsync(inventory).ConfigureAwait(false);
}
}
// Commit this page's deletions before paging on, so the ordering used by the next query
// reflects the removals: what is left of this page now occupies [skip, skip + kept).
if (removed > 0)
if (account.Vault is { } vault)
{
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await context.DeleteAsync(vault).ConfigureAwait(false);
}
skip += page.Count - removed;
if (await context.DeleteAsync(account).ConfigureAwait(false))
{
deleted++;
}
else
{
// Not silent: a bot account which survives the purge is spawned again right after it.
this._logger.LogWarning("Bot account '{LoginName}' could not be deleted.", loginName);
}
// Save per account, so a single failure does not roll back the accounts already deleted.
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
return deleted;

View File

@@ -24,8 +24,49 @@ using MUnique.OpenMU.GameLogic.PlayerActions.Items;
/// </summary>
internal static class BotJewelHandler
{
/// <summary>Upper bound of jewel consumptions per shopping trip - a player doesn't burn the whole hoard at once.</summary>
private const int MaxUsesPerTrip = 2;
/// <summary>
/// The jewels a bot has a use for: it upgrades its own gear with them (see the policy above).
/// Everything else - Chaos, Creation, Guardian, Gemstone, Harmony, the refine stones - is only
/// spendable by trading or crafting, which a bot does neither of.
/// </summary>
internal static readonly ItemIdentifier[] UsableJewels =
[
ItemConstants.JewelOfBless,
ItemConstants.JewelOfSoul,
ItemConstants.JewelOfLife,
];
/// <summary>
/// Jewels a bot may still be carrying from before but can never spend: it neither trades nor crafts.
/// They are not picked up anymore, so this is about clearing out what is already in the backpack.
/// </summary>
internal static readonly ItemIdentifier[] UnusableJewels =
[
ItemConstants.JewelOfChaos,
ItemConstants.JewelOfCreation,
ItemConstants.JewelOfGuardian,
ItemConstants.Gemstone,
ItemConstants.JewelOfHarmony,
ItemConstants.LowerRefineStone,
ItemConstants.HigherRefineStone,
];
/// <summary>
/// Jewels of Bless per shopping trip. Each kind has its OWN budget on purpose: with one shared
/// budget the Bless rule - which has a target whenever any equipped piece is below +6, and a swapped
/// in piece arrives at the level it dropped with - consumed every use, every trip, and the Soul and
/// Life rules below were never reached at all.
/// </summary>
private const int MaxBlessPerTrip = 2;
/// <summary>Jewels of Soul per shopping trip.</summary>
private const int MaxSoulPerTrip = 1;
/// <summary>Jewels of Life per shopping trip.</summary>
private const int MaxLifePerTrip = 1;
/// <summary>Safety net for the planning loop; the per-kind budgets are the real limit.</summary>
private const int MaxUsesPerTrip = MaxBlessPerTrip + MaxSoulPerTrip + MaxLifePerTrip;
/// <summary>The Jewel of Bless upgrades item levels 0..5 (see <c>BlessJewelConsumeHandlerPlugIn</c>).</summary>
private const byte BlessMaxTargetLevel = 5;
@@ -56,6 +97,9 @@ internal static class BotJewelHandler
/// <summary>Only risk a Life with at least this many in stock.</summary>
private const int MinLifeStock = 2;
/// <summary>Fallback stock per kind when the bot feature has no configuration at hand.</summary>
private const int DefaultJewelStockPerKind = 10;
private static readonly ItemConsumeAction ConsumeAction = new();
private static readonly MoveItemAction MoveAction = new();
@@ -74,8 +118,10 @@ internal static class BotJewelHandler
}
var uses = 0;
var lifeUsed = false;
while (uses < MaxUsesPerTrip && PlanNextUse(player, lifeUsed) is { } plan)
var blessLeft = MaxBlessPerTrip;
var soulLeft = MaxSoulPerTrip;
var lifeLeft = MaxLifePerTrip;
while (uses < MaxUsesPerTrip && PlanNextUse(player, blessLeft, soulLeft, lifeLeft) is { } plan)
{
if (!await ApplyJewelAsync(player, plan.Jewel, plan.Target).ConfigureAwait(false))
{
@@ -83,7 +129,18 @@ internal static class BotJewelHandler
}
uses++;
lifeUsed |= plan.IsLife;
if (IsJewel(plan.Jewel, ItemConstants.JewelOfBless))
{
blessLeft--;
}
else if (IsJewel(plan.Jewel, ItemConstants.JewelOfSoul))
{
soulLeft--;
}
else
{
lifeLeft--;
}
}
if (uses > 0)
@@ -106,8 +163,10 @@ internal static class BotJewelHandler
/// <c>null</c> when nothing sensible is left to do. Pure decision logic - exposed for unit tests.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="lifeUsed">Whether a Jewel of Life was already used this trip (at most one).</param>
internal static (Item Jewel, Item Target, bool IsLife)? PlanNextUse(Player player, bool lifeUsed)
/// <param name="blessLeft">How many Jewels of Bless may still be used this trip.</param>
/// <param name="soulLeft">How many Jewels of Soul may still be used this trip.</param>
/// <param name="lifeLeft">How many Jewels of Life may still be used this trip.</param>
internal static (Item Jewel, Item Target)? PlanNextUse(Player player, int blessLeft, int soulLeft, int lifeLeft)
{
if (player.Inventory is not { } inventory)
{
@@ -125,17 +184,19 @@ internal static class BotJewelHandler
var lifeStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfLife)).ToList();
// 1. Bless - free progress: push the weakest equipped piece towards +6.
if (blessStock.Count > 0
if (blessLeft > 0
&& blessStock.Count > 0
&& equipped.Where(i => i.CanLevelBeUpgraded() && i.Level <= BlessMaxTargetLevel)
.OrderBy(i => i.Level)
.FirstOrDefault() is { } blessTarget)
{
return (blessStock[0], blessTarget, false);
return (blessStock[0], blessTarget);
}
// 2. Soul - risky: only with a spare in stock, and only where the possible loss is bearable -
// items without luck stop at +6 -> +7 (see SoulMaxTargetLevelPlain), lucky ones may go for +9.
if (soulStock.Count >= MinSoulStock
if (soulLeft > 0
&& soulStock.Count >= MinSoulStock
&& equipped.Where(i => i.CanLevelBeUpgraded()
&& i.Level >= SoulMinTargetLevel
&& i.Level <= (HasLuck(i) ? SoulMaxTargetLevelLucky : SoulMaxTargetLevelPlain))
@@ -143,24 +204,118 @@ internal static class BotJewelHandler
.ThenBy(i => i.Level)
.FirstOrDefault() is { } soulTarget)
{
return (soulStock[0], soulTarget, false);
return (soulStock[0], soulTarget);
}
// 3. Life - sparingly: at most one per trip, only on gear that is already +6 or better. Whether
// the item can actually carry the option is the consume handler's call; a rejected consume
// keeps the jewel.
if (!lifeUsed
if (lifeLeft > 0
&& lifeStock.Count >= MinLifeStock
&& equipped.Where(i => i.IsWearable() && i.Level >= LifeMinTargetLevel)
.OrderByDescending(i => i.Level)
.FirstOrDefault() is { } lifeTarget)
{
return (lifeStock[0], lifeTarget, true);
return (lifeStock[0], lifeTarget);
}
return null;
}
/// <summary>
/// Whether the bot carries jewels it should get rid of at a merchant: any it cannot use at all, or
/// more of a usable kind than its stock limit. Deliberately cheap - the trip planner asks this on
/// every check, so unlike the full junk scan it must not plan equipment swaps.
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns><c>True</c>, if there is jewel surplus to sell.</returns>
internal static bool HasSurplus(Player player)
{
if (player.Inventory is not { } inventory)
{
return false;
}
var limit = GetStockLimit(player);
var counts = new Dictionary<ItemIdentifier, int>();
foreach (var item in inventory.Items)
{
if (item.ItemSlot < InventoryConstants.EquippableSlotsCount
|| item.Definition is not { } definition)
{
continue;
}
var identifier = new ItemIdentifier(definition.Number, definition.Group);
if (UnusableJewels.Contains(identifier))
{
return true;
}
if (!UsableJewels.Contains(identifier))
{
continue;
}
var count = counts.GetValueOrDefault(identifier) + 1;
if (count > limit)
{
return true;
}
counts[identifier] = count;
}
return false;
}
/// <summary>
/// Whether the bot has a jewel it could spend on its gear right now. Jewels are only used at the
/// end of a merchant trip - the rare, safe, player-like moment for it - so the trip planner asks
/// this to decide whether a visit is worth making at all.
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns><c>True</c>, if an upgrade is pending.</returns>
internal static bool HasPendingUpgrade(Player player)
=> PlanNextUse(player, MaxBlessPerTrip, MaxSoulPerTrip, MaxLifePerTrip) is not null;
/// <summary>
/// Whether the bot still has room in its stock for this jewel - the pickup handler asks before
/// taking one from the ground, and the merchant trade asks to tell a working stock from surplus.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="item">The jewel to judge.</param>
/// <returns><c>True</c>, if it is a usable jewel and the stock is not full yet.</returns>
internal static bool WantsMoreOf(Player player, Item item)
{
if (item.Definition is not { } definition)
{
return false;
}
var identifier = new ItemIdentifier(definition.Number, definition.Group);
return UsableJewels.Contains(identifier)
&& CountInStock(player, identifier) < GetStockLimit(player);
}
/// <summary>
/// Gets the configured number of jewels a bot keeps of each usable kind.
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns>The stock limit per kind.</returns>
internal static int GetStockLimit(Player player)
=> BotFeaturePlugIn.GetConfiguration(player.GameContext)?.GetEffectiveJewelStockPerKind()
?? DefaultJewelStockPerKind;
/// <summary>
/// Counts how many jewels of the given kind the bot carries.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="identifier">The jewel kind.</param>
/// <returns>The number of jewels of that kind in the inventory.</returns>
internal static int CountInStock(Player player, ItemIdentifier identifier)
=> player.Inventory?.Items.Count(i => IsJewel(i, identifier)) ?? 0;
/// <summary>
/// Applies one jewel to one equipped item the way a player does it: take the piece off into a free
/// backpack slot (the consume handlers refuse to modify equipped items), consume the jewel on it

View File

@@ -29,6 +29,12 @@ public sealed class BotManager
/// </summary>
public IReadOnlyCollection<BotPlayer> Bots => this._bots.Values.ToList();
/// <summary>
/// Gets the number of currently active bots, without taking a snapshot of them - the periodic task
/// asks every second whether there is anything to stop.
/// </summary>
public int BotCount => this._bots.Count;
/// <summary>
/// Spawns a bot which drives a specific character of the given account.
/// </summary>

View File

@@ -43,6 +43,24 @@ internal sealed class BotNavigator : AsyncDisposable
/// <summary>Range (tiles) around the origin that counts as "at the hunting ground".</summary>
private const int HuntingRange = 6;
/// <summary>
/// Distance (tiles) at which a hunting ground counts as half as attractive as one the bot is standing on.
/// See <see cref="GroundWeight"/>.
/// </summary>
private const int ProximityFalloff = 64;
/// <summary>
/// Width (tiles) of the box a hunting ground point is sampled from when the spawn area itself is smaller.
/// See <see cref="TryPickWalkablePoint"/>.
/// </summary>
private const int GroundScatterSpan = 2 * HuntingRange;
/// <summary>
/// Share of the shopping cooldown the next trip is spread over, so that bots which shopped together
/// don't come back together. See <see cref="NextShoppingCheckUtc"/>.
/// </summary>
private const double ShoppingSpread = 0.4;
/// <summary>
/// How far the bot looks for an actual live monster to home in on. MU spawn areas span most of the map
/// (e.g. Lorencia's are ~86x233 / ~105x68 tiles) with only a few dozen monsters each, so their monsters
@@ -119,6 +137,9 @@ internal sealed class BotNavigator : AsyncDisposable
/// </summary>
private const int DeathSiteAvoidanceRange = 30;
/// <summary>The game's own warp action, so a bot travels on exactly a player's terms - fare included.</summary>
private static readonly WarpAction WarpAction = new();
private static readonly TimeSpan EvaluationInterval = TimeSpan.FromSeconds(1);
private static readonly TimeSpan EmptyGroundGrace = TimeSpan.FromSeconds(8);
@@ -134,6 +155,23 @@ internal sealed class BotNavigator : AsyncDisposable
/// <summary>Minimum time between two cross-map warps, so a bot does not bounce between maps (a real player does not hop maps every minute either).</summary>
private static readonly TimeSpan WarpCooldown = TimeSpan.FromMinutes(3);
/// <summary>
/// How long a bot may go without landing a single hit before it treats its map as barren and steps
/// down (see <see cref="TryPickEasierMap"/>). Long enough that a walk across a map, a merchant trip
/// or a quiet stretch between spawns does not count as one.
/// </summary>
private static readonly TimeSpan BarrenMapDuration = TimeSpan.FromMinutes(3);
/// <summary>
/// Share of the picks the best ranked map takes when the bot chooses where to hunt (see
/// <see cref="PickByRank"/>); the runners-up split what is left, on the same terms. Below a half on
/// purpose: at a half the fourth map of a level band and everything behind it share a few percent
/// between them, which left Karutan, Icarus and Kanturu with three or four bots each while the top
/// map held a hundred. The bot still prefers the best ground - every candidate it draws from is
/// already a step up from where it stands - it just does not treat the runner-up as worthless.
/// </summary>
private const double BestCandidateShare = 0.35;
/// <summary>Cooldown for following the party leader to another map (shorter, so the group regroups quickly).</summary>
private static readonly TimeSpan FollowWarpCooldown = TimeSpan.FromSeconds(20);
@@ -198,7 +236,14 @@ internal sealed class BotNavigator : AsyncDisposable
private int _travelPathIndex;
private Point _travelPathTarget;
private Point? _shoppingTarget;
private DateTime _nextShoppingCheckUtc = DateTime.MinValue;
/// <summary>
/// When this bot looks at its supplies again. Bots come up with the server, so starting them all at
/// "now" sent the whole population to the same merchant in the same minute - and since the cooldown
/// was the same for everyone, nothing ever broke that herd apart again. The first check is therefore
/// spread over the cooldown, and every following one is spread around it.
/// </summary>
private DateTime _nextShoppingCheckUtc = DateTime.UtcNow + (ShoppingCooldown * Rand.NextDouble());
private DateTime? _resetDueAtUtc;
private short _leaderMapNumber;
private DateTime _leaderOnMapSinceUtc = DateTime.MinValue;
@@ -230,6 +275,24 @@ internal sealed class BotNavigator : AsyncDisposable
EvaluationInterval);
}
/// <summary>
/// Rates a spawn area as a hunting ground for a bot standing at the given point.
/// </summary>
/// <param name="area">The spawn area.</param>
/// <param name="from">The point the bot measures from.</param>
/// <returns>The relative weight of the area.</returns>
internal static int GroundWeight(MonsterSpawnArea area, Point from)
{
// Manhattan distances on a 256x256 map reach ~460, so a linear "300 - distance" term rated a
// ground next to the entrance up to 299 while everything past the halfway point clamped to 1.
// Bots enter a map through the same gate and therefore all measure from the same spot, so that
// ratio made them queue up on the handful of spawns nearest to it. A hyperbolic falloff keeps
// the preference for close grounds - travel is still time not spent hunting - without writing
// off the rest of the map.
var proximity = (ProximityFalloff * ProximityFalloff) / (ProximityFalloff + GroundDistance(area, from));
return Math.Max(1, (int)area.Quantity) * Math.Max(1, proximity);
}
/// <inheritdoc />
protected override async ValueTask DisposeAsyncCore()
{
@@ -274,13 +337,18 @@ internal sealed class BotNavigator : AsyncDisposable
private static int GetMonsterLevel(MonsterDefinition definition)
=> (int)(definition.Attributes.FirstOrDefault(a => a.AttributeDefinition == Stats.Level)?.Value ?? 0f);
private static int GroundWeight(MonsterSpawnArea area, Point from)
/// <summary>Manhattan distance between the center of the spawn area and the given point.</summary>
/// <summary>
/// Returns the time of the next supply check, spread around <see cref="ShoppingCooldown"/> so that bots
/// which just shopped together don't queue up at the merchant together again.
/// </summary>
/// <returns>The time of the next supply check.</returns>
private static DateTime NextShoppingCheckUtc()
{
var proximity = Math.Max(1, 300 - GroundDistance(area, from));
return Math.Max(1, (int)area.Quantity) * proximity;
var spread = 1 + (((Rand.NextDouble() * 2) - 1) * ShoppingSpread);
return DateTime.UtcNow + (ShoppingCooldown * spread);
}
/// <summary>Manhattan distance between the center of the spawn area and the given point.</summary>
private static int GroundDistance(MonsterSpawnArea area, Point from)
{
var centerX = (area.X1 + area.X2) / 2;
@@ -564,7 +632,7 @@ internal sealed class BotNavigator : AsyncDisposable
this._player.Logger.LogInformation("Bot '{Name}' gives up its shopping trip: no route to the merchant at {Target}.", this._player.Name, target);
this._shoppingTarget = null;
this._player.IsOnShoppingTrip = false;
this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCooldown;
this._nextShoppingCheckUtc = NextShoppingCheckUtc();
}
return true;
@@ -579,7 +647,7 @@ internal sealed class BotNavigator : AsyncDisposable
this._shoppingTarget = null;
this._player.IsOnShoppingTrip = false;
this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCooldown;
this._nextShoppingCheckUtc = NextShoppingCheckUtc();
this._lastMoveUtc = DateTime.UtcNow; // standing at the shop is not "stuck"
return true;
}
@@ -595,7 +663,7 @@ internal sealed class BotNavigator : AsyncDisposable
return false;
}
if (BotShoppingHandler.FindMerchantPosition(map) is not { } merchantPosition)
if (BotShoppingHandler.FindMerchantPosition(this._player, map) is not { } merchantPosition)
{
// No merchant lives on this map at all (the Dungeon has no town). Shopping here would
// starve the bot's logistics for as long as it stays - no potion restock, no selling, a
@@ -815,8 +883,9 @@ internal sealed class BotNavigator : AsyncDisposable
{
ExitGate? warpListGate = null;
if (leader.CurrentMap is { } targetMap
&& !this.TryGetLegalWarpGate(targetMap.Definition, out warpListGate)
&& targetMap.Definition.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number)
&& (this.TryGetLegalWarp(targetMap.Definition, out var leaderWarp)
? (warpListGate = leaderWarp.Gate) is null
: targetMap.Definition.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number))
{
// The leader moved to a map the bot's plain character level cannot legally enter
// (level gates map access, the same rule as everywhere else). Rather than trail
@@ -982,36 +1051,76 @@ internal sealed class BotNavigator : AsyncDisposable
var currentMap = this._player.CurrentMap?.Definition;
var mapIsHostile = currentMap is not null && this.BestSafeLevel(currentMap) == 0;
var mapIsIllegal = currentMap is not null
&& !this.TryGetLegalWarpGate(currentMap, out _)
&& !this.TryGetLegalWarp(currentMap, out _)
&& currentMap.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number;
var mustEscape = mapIsHostile || mapIsIllegal;
if ((plainLevel < MinWarpLevel && !mustEscape)
// A map can pass every check above and still pay the bot nothing: the safe monsters are a rare
// kind among ones it must refuse, or stronger bots empty the grounds before it arrives. The bot
// notices the way a player would - it has not landed a hit in minutes - and steps DOWN to easier
// ground to earn its way back up, instead of walking between hunting grounds forever.
var mapIsBarren = DateTime.UtcNow - this._player.LastAttackUtc > BarrenMapDuration;
if ((plainLevel < MinWarpLevel && !mustEscape && !mapIsBarren)
|| DateTime.UtcNow - this._lastWarpUtc < WarpCooldown)
{
return false;
}
// When escaping, any legal map with something safe to hunt beats staying - and with no legal
// warp target at all (a freshly reset veteran may be below every warp requirement), the bot
// retreats to its class home town, like a player using a town scroll.
if (!this.TryPickBetterMap(mustEscape, out var targetGate, out var targetMap, out var targetLevel)
&& !(mustEscape && this.TryGetHomeEscapeGate(out targetGate, out targetMap, out targetLevel)))
// When escaping, any legal map with something safe to hunt beats staying. The home retreat is
// the last resort AND the safety valve: a bot which cannot afford a single warp - or is below
// every warp requirement, as a freshly reset veteran is - would otherwise be stranded on ground
// that pays it nothing, unable to earn the fare out of it. Walking home is free, like a player
// taking the long way back, and its home map is starter ground it can always hunt.
WarpInfo? fare = null;
ExitGate targetGate;
GameMapDefinition? targetMap;
int targetLevel;
if ((mapIsBarren && this.TryPickEasierMap(out var easierWarp, out targetMap, out targetLevel))
|| this.TryPickBetterMap(mustEscape, out easierWarp, out targetMap, out targetLevel))
{
fare = easierWarp;
targetGate = easierWarp.Gate!;
}
else if ((mustEscape || mapIsBarren)
&& this.TryGetHomeEscapeGate(out var homeGate, out targetMap, out targetLevel))
{
targetGate = homeGate;
}
else
{
return false;
}
// The barren timer restarts with the move: the new map deserves the same chance to prove itself
// before it is judged, and without this every following tick would warp again.
this._player.LastAttackUtc = DateTime.UtcNow;
this._lastWarpUtc = DateTime.UtcNow;
this._hasDestination = false;
this._travelPath = null;
this._player.Logger.LogInformation(
"Bot {Character} (level {Level}) warping to map {Map} (monsters ~{MonsterLevel}).",
"Bot {Character} (level {Level}) warping to map {Map} (monsters ~{MonsterLevel}, fare {Fare} zen).",
this._player.Name,
botLevel,
targetMap.Name,
targetLevel);
await this._player.WarpToAsync(targetGate).ConfigureAwait(false);
await this.TryPersistCurrentMapAsync(targetMap).ConfigureAwait(false);
targetMap!.Name,
targetLevel,
fare?.Costs ?? 0);
if (fare is not null)
{
// Through the player action, so the bot pays the fare and passes every check the game makes
// of a player warping there - including any this navigator does not know about.
await WarpAction.WarpToAsync(this._player, fare).ConfigureAwait(false);
}
else
{
await this._player.WarpToAsync(targetGate).ConfigureAwait(false);
}
// Where the character ACTUALLY ended up, not where it was sent: the warp action re-checks what
// the candidate filter checked and may refuse, and remembering a refused trip as a move would
// reload the bot onto a map it never reached.
await this.TryPersistCurrentMapAsync(this._player.CurrentMap?.Definition ?? targetMap).ConfigureAwait(false);
return true;
}
@@ -1208,8 +1317,10 @@ internal sealed class BotNavigator : AsyncDisposable
return;
}
// A stack holds as many charges as the item definition allows - no more (see EmergencyPotionCharges).
var stackSize = Math.Max((byte)1, definition.Durability);
// Only ever up to the emergency amount, never a full stack: a Large Healing Potion holds 255
// charges, four times what a bot stocks at a merchant, so handing out whole stacks here made the
// fallback the bot's normal potion supply and the shopping trip never had a reason to restock.
var stackSize = Math.Min(Math.Max((byte)1, definition.Durability), (byte)EmergencyPotionCharges);
foreach (var potion in potions.Where(p => p.Durability < stackSize))
{
charges += (int)(stackSize - potion.Durability);
@@ -1322,7 +1433,7 @@ internal sealed class BotNavigator : AsyncDisposable
/// <summary>
/// The bot's reset-aware effective level (see <see cref="BotResetHandler.GetEffectiveLevel"/>),
/// used for hunting decisions (which monsters pay off). Map ACCESS is deliberately not decided
/// by this - the game gates warps by the plain character level (see <see cref="TryGetLegalWarpGate"/>),
/// by this - the game gates warps by the plain character level (see <see cref="TryGetLegalWarp"/>),
/// and the bots must obey the same rule.
/// </summary>
private int GetBotLevel() => BotResetHandler.GetEffectiveLevel(this._player);
@@ -1337,23 +1448,30 @@ internal sealed class BotNavigator : AsyncDisposable
/// <param name="mapDefinition">The target map.</param>
/// <param name="gate">The warp target gate, if a legal entry exists.</param>
/// <returns>True, if the bot may warp to the map.</returns>
private bool TryGetLegalWarpGate(GameMapDefinition mapDefinition, [MaybeNullWhen(false)] out ExitGate gate)
private bool TryGetLegalWarp(GameMapDefinition mapDefinition, [MaybeNullWhen(false)] out WarpInfo warp)
{
gate = null;
warp = null;
if (this._player.SelectedCharacter is not { } character)
{
return false;
}
var plainLevel = (int)(this._player.Attributes?[Stats.Level] ?? 1);
gate = this._player.GameContext.Configuration.WarpList
warp = this._player.GameContext.Configuration.WarpList
.Where(w => w.Gate?.Map?.Number == mapDefinition.Number
&& character.GetEffectiveMoveLevelRequirement(w.LevelRequirement) <= plainLevel)
.Select(w => w.Gate!)
.FirstOrDefault();
return gate is not null;
return warp is not null;
}
/// <summary>
/// Whether the bot can pay the warp's fare. A bot travels on the same terms as a player - it pays
/// what the warp costs - so a map it cannot afford is not offered to it in the first place, rather
/// than chosen and then refused by <see cref="WarpAction"/>.
/// </summary>
/// <param name="warp">The warp to pay for.</param>
private bool CanAffordWarp(WarpInfo warp) => this._player.Money >= warp.Costs;
/// <summary>
/// Gets the escape gate to the bot's class home town, for when it must leave its current map but is
/// below every warp requirement (e.g. a freshly reset veteran) - the equivalent of a town scroll.
@@ -1446,36 +1564,89 @@ internal sealed class BotNavigator : AsyncDisposable
return area is { Quantity: > 0, SpawnTrigger: SpawnTrigger.Automatic, MonsterDefinition.ObjectKind: NpcObjectKind.Monster };
}
/// <summary>
/// Picks the legally warpable map with the strongest monsters the bot can safely fight among those
/// EASIER than where it stands now - one step down, not a retreat to the starter map. Used when the
/// current map pays nothing (see <see cref="BarrenMapDuration"/>): the bot keeps stepping down every
/// barren stretch until it finds ground it can actually farm, and the regular map choice carries it
/// back up as its gear and level recover.
/// </summary>
/// <param name="gate">The warp gate of the chosen map.</param>
/// <param name="mapDefinition">The chosen map.</param>
/// <param name="monsterLevel">The level of the strongest safe monster there.</param>
private bool TryPickEasierMap(out WarpInfo warp, out GameMapDefinition mapDefinition, out int monsterLevel)
{
warp = default!;
mapDefinition = default!;
monsterLevel = 0;
var current = this._player.CurrentMap?.Definition;
// With nothing safe here at all, every huntable map is a step down - the comparison must not
// rule them all out.
var currentLevel = current is null ? int.MaxValue : this.BestSafeLevel(current);
if (currentLevel <= 0)
{
currentLevel = int.MaxValue;
}
foreach (var candidate in this._player.GameContext.Configuration.Maps)
{
if (ReferenceEquals(candidate, current))
{
continue;
}
var best = this.BestSafeLevel(candidate);
if (best <= 0 || best >= currentLevel || best <= monsterLevel)
{
continue;
}
if (!candidate.TryGetRequirementError(this._player, out _)
&& this.TryGetLegalWarp(candidate, out var candidateWarp)
&& this.CanAffordWarp(candidateWarp))
{
warp = candidateWarp;
mapDefinition = candidate;
monsterLevel = best;
}
}
return mapDefinition is not null;
}
/// <summary>
/// Picks the legally warpable map (other than the current one) that offers the strongest monsters the
/// bot can still safely handle, if it is meaningfully better than the current map, with its warp gate.
/// In escape mode (fleeing a hostile or illegal map) any legal map with something safe to hunt counts,
/// no matter how it compares to the current one.
/// </summary>
private bool TryPickBetterMap(bool escape, out ExitGate gate, out GameMapDefinition mapDefinition, out int monsterLevel)
private bool TryPickBetterMap(bool escape, out WarpInfo warp, out GameMapDefinition mapDefinition, out int monsterLevel)
{
// A mastered bot earns nothing below the master-experience floor, so it first looks for a map
// which pays at all. Only when no map within its reach offers monsters above the floor that it
// can safely fight does it fall back to hunting by safety alone: standing on a map it cannot
// survive would not earn it master experience either, and its gear still improves meanwhile.
var floor = this.MasterExperienceFloor();
return (floor > 0 && this.TryPickBetterMapCore(escape, floor, out gate, out mapDefinition, out monsterLevel))
|| this.TryPickBetterMapCore(escape, 0, out gate, out mapDefinition, out monsterLevel);
return (floor > 0 && this.TryPickBetterMapCore(escape, floor, out warp, out mapDefinition, out monsterLevel))
|| this.TryPickBetterMapCore(escape, 0, out warp, out mapDefinition, out monsterLevel);
}
/// <summary>
/// Picks the best map (see <see cref="TryPickBetterMap"/>), counting only monsters of at least
/// <paramref name="minimumMonsterLevel"/>.
/// </summary>
private bool TryPickBetterMapCore(bool escape, int minimumMonsterLevel, out ExitGate gate, out GameMapDefinition mapDefinition, out int monsterLevel)
private bool TryPickBetterMapCore(bool escape, int minimumMonsterLevel, out WarpInfo warp, out GameMapDefinition mapDefinition, out int monsterLevel)
{
gate = default!;
warp = default!;
mapDefinition = default!;
monsterLevel = 0;
var current = this._player.CurrentMap?.Definition;
var threshold = escape ? 1 : (current is null ? 0 : this.BestSafeLevel(current, minimumMonsterLevel)) + WarpImprovementMargin;
var candidates = new List<(WarpInfo Warp, GameMapDefinition Map, int Level)>();
foreach (var candidate in this._player.GameContext.Configuration.Maps)
{
if (ReferenceEquals(candidate, current))
@@ -1489,16 +1660,48 @@ internal sealed class BotNavigator : AsyncDisposable
continue;
}
if (this.TryGetLegalWarpGate(candidate, out var warpGate))
if (this.TryGetLegalWarp(candidate, out var candidateWarp) && this.CanAffordWarp(candidateWarp))
{
gate = warpGate;
mapDefinition = candidate;
monsterLevel = best;
threshold = best + 1; // keep only a strictly-stronger map after this one
candidates.Add((candidateWarp, candidate, best));
}
}
return mapDefinition is not null;
if (candidates.Count == 0)
{
return false;
}
// Always taking the single strongest map emptied the world: the maps of a level band differ by a
// few monster levels, so one of them is every bot's answer and the rest stand deserted - hundreds
// of bots on Vulcanus while Tarkan, Karutan and Kanturu, which their level opens, see nobody. The
// strongest map still wins most of the picks (see BestCandidateShare); the runners-up share the
// rest, so the population spreads over the maps a player of that band would choose between.
var ranked = candidates.OrderByDescending(c => c.Level).ToList();
var picked = ranked[PickByRank(ranked.Count)];
warp = picked.Warp;
mapDefinition = picked.Map;
monsterLevel = picked.Level;
return true;
}
/// <summary>
/// Picks an index into a list ranked best-first, each rank taking
/// <see cref="BestCandidateShare"/> of what the ranks before it left over. Deliberately not a
/// uniform draw - a bot should prefer the best ground it can hunt, just not to the exclusion of
/// everything else.
/// </summary>
/// <param name="count">The number of ranked candidates.</param>
private static int PickByRank(int count)
{
for (var rank = 0; rank < count - 1; rank++)
{
if (Rand.NextDouble() < BestCandidateShare)
{
return rank;
}
}
return count - 1;
}
/// <summary>
@@ -1580,15 +1783,27 @@ internal sealed class BotNavigator : AsyncDisposable
private bool TryPickWalkablePoint(GameMap map, MonsterSpawnArea area, Point? avoidCenter, out Point point)
{
var minX = Math.Min(area.X1, area.X2);
var maxX = Math.Max(area.X1, area.X2);
var minY = Math.Min(area.Y1, area.Y2);
var maxY = Math.Max(area.Y1, area.Y2);
int minX = Math.Min(area.X1, area.X2);
int maxX = Math.Max(area.X1, area.X2);
int minY = Math.Min(area.Y1, area.Y2);
int maxY = Math.Max(area.Y1, area.Y2);
// Not every map states its spawns as rectangles: LaCleon, for one, lists every spawn as a single
// tile, and so do 44 of the 55 maps which hold more than ten of them. Sampling "inside" such an
// area can only ever return that one tile, so every bot which picks it walks onto the very same
// spot. Scatter around it instead - the bot still arrives within combat range of the monster.
var isSingleSpot = (maxX - minX) < GroundScatterSpan && (maxY - minY) < GroundScatterSpan;
var center = new Point((byte)((minX + maxX) / 2), (byte)((minY + maxY) / 2));
for (var attempt = 0; attempt < MaxPointPickAttempts; attempt++)
{
var x = Rand.NextInt(minX, maxX + 1);
var y = Rand.NextInt(minY, maxY + 1);
// GetRandomCoordinate already keeps to the map, retries unwalkable picks and falls back to the
// point itself when the surroundings offer nothing - so a spawn on a ledge stays usable.
var candidate = isSingleSpot
? map.Terrain.GetRandomCoordinate(center, HuntingRange)
: new Point((byte)Rand.NextInt(minX, maxX + 1), (byte)Rand.NextInt(minY, maxY + 1));
int x = candidate.X;
int y = candidate.Y;
if (!map.Terrain.WalkMap[x, y] || map.Terrain.SafezoneMap[x, y])
{
continue;

View File

@@ -67,6 +67,22 @@ internal static class BotProgression
/// </summary>
private static readonly short[] ExcludedBuffSkillNumbers = [18, 219, 221, 222];
/// <summary>
/// The skills which the game only activates on the castle siege map. Nothing in the skill data marks
/// them, but the client does not let a player cast them anywhere else, so a bot hunting with one is a
/// bot doing something no player can do. They are also the strongest numbers each class has - they are
/// meant to be - which is exactly why a "pick the strongest" rule walks straight into them: a Dark
/// Knight fought with Crescent Moon Slash, every elf with Starfall and every Rage Fighter with Charge.
/// (44 Crescent Moon Slash, 45 Lance, 46 Starfall, 57 Spiral Slash, 73 Mana Rays, 74 Fire Blast,
/// 269 Charge - the same set the client refuses outside a siege.)
/// The second group are the skills of the siege roles - the guild's battle masters and its master -
/// which are handed out for the siege and are not attacks at all: 67 Stun, 68 Cancel Stun,
/// 69 Swell Mana, 70 Invisibility, 71 Cancel Invisibility, 72 Abolish Magic. Stun in particular is
/// indistinguishable from a real attack skill by its data alone: like Twisting Slash and Power Slash
/// it is an area skill with no damage of its own and a single hit.
/// </summary>
private static readonly short[] CastleSiegeOnlySkillNumbers = [44, 45, 46, 57, 67, 68, 69, 70, 71, 72, 73, 74, 269];
/// <summary>
/// Gets the class the character evolves into at <see cref="ClassEvolutionLevel"/>, or null when the
/// class has no (in-scope) evolution.
@@ -294,13 +310,21 @@ internal static class BotProgression
return false;
}
if (skill.AttackDamage > 0
&& skill.SkillType is SkillType.DirectHit
or SkillType.AreaSkillAutomaticHits
or SkillType.AreaSkillExplicitHits
or SkillType.AreaSkillExplicitTarget)
if (CastleSiegeOnlySkillNumbers.Contains(skill.Number))
{
return true;
return false;
}
if (IsAttackSkill(skill))
{
// Worth learning if it adds damage of its own, hits more than once, or hits more than one
// target. Judging by AttackDamage alone locked a Rage Fighter out of its entire arsenal:
// Killing Blow, Chain Drive, Dragon Roar and Phoenix Shot all carry a flat bonus of zero and
// four hits instead, because their damage comes from the weapon - which is also how the
// server pays them out.
return skill.AttackDamage > 0
|| skill.NumberOfHitsPerAttack > 1
|| IsAreaSkill(skill);
}
return skill.SkillType is SkillType.Buff or SkillType.Regeneration
@@ -308,6 +332,45 @@ internal static class BotProgression
&& !ExcludedBuffSkillNumbers.Contains(skill.Number);
}
/// <summary>
/// Determines whether the skill deals damage to a target, as opposed to buffing, summoning or the like.
/// </summary>
/// <param name="skill">The skill.</param>
public static bool IsAttackSkill(Skill skill)
=> skill.SkillType is SkillType.DirectHit
or SkillType.AreaSkillAutomaticHits
or SkillType.AreaSkillExplicitHits
or SkillType.AreaSkillExplicitTarget;
/// <summary>
/// Determines whether the skill hits more than its primary target.
/// </summary>
/// <param name="skill">The skill.</param>
public static bool IsAreaSkill(Skill skill)
=> skill.SkillType is SkillType.AreaSkillAutomaticHits
or SkillType.AreaSkillExplicitHits
or SkillType.AreaSkillExplicitTarget;
/// <summary>
/// Determines whether the skill is one the game only activates during a castle siege, which a bot
/// therefore never uses while hunting - not even when it already knows it, as a Dark Knight does:
/// Crescent Moon Slash is handed to every one of them when the character is created.
/// </summary>
/// <param name="skill">The skill.</param>
public static bool IsCastleSiegeOnly(Skill skill) => CastleSiegeOnlySkillNumbers.Contains(skill.Number);
/// <summary>
/// Determines whether the skill belongs to a PET rather than to the character, and may therefore
/// only be used while that pet is actually equipped. Plasma Storm is the Fenrir's, and nothing in
/// the skill's own numbers gives the missing pet away: the attribute behind its damage
/// (<see cref="Attributes.Stats.FenrirBaseDmg"/>) is derived from the character's own strength,
/// agility, vitality and energy, so it is large for any high level character - with or without the
/// pet. Scoring it by that attribute alone handed Plasma Storm, the longest ranged skill most
/// classes own, to a whole population riding nothing.
/// </summary>
/// <param name="skill">The skill.</param>
public static bool RequiresPet(Skill skill) => skill.DamageType == DamageType.Fenrir;
/// <summary>
/// Determines whether the character meets the skill's learn requirements (the same ones the game
/// enforces when casting, e.g. total energy for wizard spells or character level for knight skills).

View File

@@ -46,10 +46,11 @@ internal sealed class BotServerPartition
public int AccountCount { get; }
/// <summary>
/// Gets a value indicating whether this server generates the bot population. Exactly one server
/// does it (the one which animates the first account), so the generation of the accounts - and of
/// their unique character names - never runs twice at the same time. The other servers simply find
/// their accounts once they exist; until then, their spawns are retried by the maintenance pass.
/// Gets a value indicating whether this server generates the bot population - and, likewise, carries
/// out the requested reset and purge. Exactly one server does it (the first of the deployment), so
/// the generation of the accounts - and of their unique character names - never runs twice at the
/// same time. The other servers simply find their accounts once they exist; until then, their spawns
/// are retried by the maintenance pass.
/// </summary>
public bool IsGenerator { get; }
@@ -120,18 +121,27 @@ internal sealed class BotServerPartition
byte serverId,
int requestedAccounts)
{
var servers = capacities.Where(c => c.Capacity > 0).ToList();
var allServers = capacities.ToList();
var servers = allServers.Where(c => c.Capacity > 0).ToList();
var totalCapacity = servers.Sum(server => (long)server.Capacity);
// Who generates - and purges - the population is decided by the SET of servers alone, never by
// how many accounts are configured: a deployment which currently wants zero bots still needs an
// owner for the destructive operations, otherwise "delete all bots" would silently do nothing on
// every server. The first server (they all walk the list in the same order) takes the role.
var owner = servers.Count > 0 ? servers[0].ServerId : allServers.Select(server => (byte?)server.ServerId).FirstOrDefault();
var isGenerator = owner == serverId;
if (totalCapacity == 0 || requestedAccounts <= 0)
{
return (new BotServerPartition(1, 0, false), 0);
return (new BotServerPartition(1, 0, isGenerator), 0);
}
// What does not fit into the servers' share stays offline; those accounts wake up as soon as the
// deployment offers the room (another game server, a higher player limit or bot capacity share).
var assignedAccounts = (int)Math.Min(requestedAccounts, totalCapacity);
var partition = new BotServerPartition(1, 0, false);
var partition = new BotServerPartition(1, 0, isGenerator);
long capacitySoFar = 0;
var accountsSoFar = 0;
foreach (var (currentServer, capacity) in servers)
@@ -144,8 +154,7 @@ internal sealed class BotServerPartition
var share = accountsUpToHere - accountsSoFar;
if (currentServer == serverId && share > 0)
{
// The server which owns the first account generates the population.
partition = new BotServerPartition(accountsSoFar + 1, share, accountsSoFar == 0);
partition = new BotServerPartition(accountsSoFar + 1, share, isGenerator);
}
accountsSoFar = accountsUpToHere;

View File

@@ -4,6 +4,7 @@
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions;
@@ -12,51 +13,48 @@ using MUnique.OpenMU.Pathfinding;
/// <summary>
/// Lets a bot trade with town merchants like a real player: when the backpack silts up or the potions
/// run low, the bot visits a merchant, sells its junk loot for Zen and buys potion refills with it -
/// closing the economic loop (materializing supplies out of thin air stays only as an emergency
/// fallback, see the low threshold in <see cref="BotNavigator"/>). The trade uses the regular player
/// actions (<see cref="TalkNpcAction"/>, <see cref="SellItemToNpcAction"/>, <see cref="BuyNpcItemAction"/>),
/// and while the dialog is open the player state pauses the combat AI - the bot visibly "shops".
/// run low, the bot visits a merchant, sells its junk loot for Zen, repairs its gear and buys refills
/// with it - closing the economic loop (materializing supplies out of thin air stays only as an
/// emergency fallback, see the low threshold in <see cref="BotNavigator"/>). The trade uses the regular
/// player actions (<see cref="TalkNpcAction"/>, <see cref="SellItemToNpcAction"/>,
/// <see cref="BuyNpcItemAction"/>, <see cref="ItemRepairAction"/>), and while the dialog is open the
/// player state pauses the combat AI - the bot visibly "shops".
/// </summary>
internal static class BotShoppingHandler
{
/// <summary>Start a shopping trip when fewer free backpack slots than this remain.</summary>
private const int FreeSlotPressure = 20;
/// <summary>Start a shopping trip when a potion kind has fewer charges than this.</summary>
private const int PotionLowThreshold = 40;
/// <summary>Go restocking once a potion kind holds less than this share of the target stock.</summary>
private const int PotionLowThresholdPercent = 66;
/// <summary>
/// Stop buying refills once this many charges are stocked. Merchants sell potions by the piece,
/// so this target keeps a trip affordable (roughly a bot's income between two trips) while lasting
/// a good while of hunting.
/// </summary>
private const int PotionTargetCharges = 60;
/// <summary>Maximum purchases per potion kind per trip.</summary>
/// <summary>Maximum purchases per potion kind per trip - a safety bound, the stock target is the real limit.</summary>
private const int MaxPurchasesPerKind = 20;
/// <summary>Maximum jewels bought per trip: a player buys one now and then, not a hoard at once.</summary>
private const int MaxJewelPurchasesPerTrip = 3;
/// <summary>Zen the bot keeps in reserve - it stops buying rather than spend its last coin.</summary>
private const int MinZenReserve = 10000;
/// <summary>
/// Zen below which a trip for potions alone is pointless - the cheapest healing item a stock
/// merchant sells still costs more than this, so the bot would walk there and buy nothing.
/// </summary>
private const int MinPotionMoney = 1000;
/// <summary>
/// Zen a bot keeps back before it spends anything on jewels. Jewels are a luxury next to potions and
/// repairs, which keep the bot alive and fighting, so it only buys them out of real surplus.
/// </summary>
private const int JewelPurchaseReserve = 10_000_000;
private static readonly TalkNpcAction TalkAction = new();
private static readonly SellItemToNpcAction SellAction = new();
private static readonly BuyNpcItemAction BuyAction = new();
private static readonly ItemRepairAction RepairAction = new();
private static readonly CloseNpcDialogAction CloseAction = new();
private static readonly ItemIdentifier[] Valuables =
[
ItemConstants.JewelOfChaos,
ItemConstants.JewelOfBless,
ItemConstants.JewelOfSoul,
ItemConstants.JewelOfLife,
ItemConstants.JewelOfCreation,
ItemConstants.JewelOfGuardian,
ItemConstants.Gemstone,
ItemConstants.JewelOfHarmony,
ItemConstants.LowerRefineStone,
ItemConstants.HigherRefineStone,
];
private static readonly ItemPriceCalculator PriceCalculator = new();
/// <summary>
/// Determines whether the bot should go shopping: the backpack is filling up with sellable junk,
@@ -70,41 +68,65 @@ internal static class BotShoppingHandler
return false;
}
var freeSlots = inventory.FreeSlots.Count();
if (freeSlots < FreeSlotPressure && inventory.Items.Any(i => IsSellableJunk(player, i)))
if (IsUnderSlotPressure(inventory) && GetSellableJunk(player, inventory).Count > 0)
{
return true;
}
if (player.Money > 5000 && GetLowPotionKinds(player).Any())
// A potion trip needs something to pay with: Zen, or loot to turn into Zen once it is there.
// A broke bot buys nothing, so the trigger still stands when it gets back - and it sets off
// again, and again, and never hunts, which is the only way it could have earned the money.
// The emergency refill (see BotNavigator) keeps it alive meanwhile, at a stock deliberately
// below this target - which is exactly what made the loop permanent rather than occasional.
if (GetLowPotionKinds(player).Any()
&& (player.Money >= MinPotionMoney || GetSellableJunk(player, inventory).Count > 0))
{
return true;
}
return false;
// Both remaining triggers exist because a merchant trip is the ONLY moment a bot can turn its
// loot into anything: selling and jewel spending both happen there. A filling backpack alone is
// not enough of a trigger - it is exactly what the rest of this class stops happening, so a
// tidy bot would sit on a hoard it can neither sell nor spend, forever.
return BotJewelHandler.HasSurplus(player)
|| BotJewelHandler.HasPendingUpgrade(player);
}
/// <summary>
/// Finds the position of a merchant NPC on the map, preferring one which sells potions. The
/// search runs over the map's LIVE objects, not the spawn configuration: wandering merchants
/// exist in the configuration but are only spawned now and then (their spawn trigger is not
/// automatic) - a bot walking to a configured but unspawned merchant would wait at an empty
/// spot and give up its trip, forever.
/// Finds the position of a merchant NPC on the map, preferring the one which sells what the bot
/// needs right now. The search runs over the map's LIVE objects, not the spawn configuration:
/// wandering merchants exist in the configuration but are only spawned now and then (their spawn
/// trigger is not automatic) - a bot walking to a configured but unspawned merchant would wait at an
/// empty spot and give up its trip, forever.
/// </summary>
/// <param name="player">The bot player, whose current needs rank the merchants.</param>
/// <param name="map">The game map.</param>
public static Point? FindMerchantPosition(GameMap map)
public static Point? FindMerchantPosition(OfflinePlayer player, GameMap map)
{
// Covers the whole 256x256 map from its center.
var merchants = map.GetNpcsInRange(new Point(128, 128), 256)
.Where(n => n.Definition is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore.Items.Count: > 0 })
.ToList();
var best = merchants.FirstOrDefault(m => SellsPotions(m.Definition.MerchantStore!)) ?? merchants.FirstOrDefault();
// A map can have a merchant and still be useless for what the bot came for: Crywolf has a
// blacksmith and a wandering merchant, neither of which stocks a single potion. Reporting "none
// here" sends the bot home to a real town instead of walking it to a shop that cannot help it,
// every cooldown, forever.
if (GetLowPotionKinds(player).Any()
&& !merchants.Any(m => SellsPotions(m.Definition.MerchantStore!)))
{
return null;
}
var best = merchants
.OrderByDescending(m => ScoreMerchant(player, m.Definition.MerchantStore!))
.FirstOrDefault();
return best?.Position;
}
/// <summary>
/// Performs the actual trade with the merchant standing near the given position: opens the dialog,
/// sells the junk loot, buys potion refills and closes the dialog again.
/// sells the junk loot, repairs the gear, buys refills and closes the dialog again.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="map">The game map.</param>
@@ -131,46 +153,35 @@ internal static class BotShoppingHandler
try
{
var soldCount = 0;
foreach (var junk in inventory.Items.Where(i => IsSellableJunk(player, i)).ToList())
{
await SellAction.SellItemAsync(player, junk.ItemSlot).ConfigureAwait(false);
soldCount++;
}
// Repairing first is not cosmetic: it is the one purchase the bot always makes, and for a
// bot sitting at the money limit the Zen it spends is the only headroom its sales will
// have. Selling first meant every sale was refused and the junk was destroyed instead,
// while the repair a moment later freed enough room to have sold a good part of it.
var repaired = await RepairGearAsync(player).ConfigureAwait(false);
var (sold, unsold) = await SellJunkAsync(player, inventory).ConfigureAwait(false);
var boughtCount = 0;
var store = player.OpenedNpc.Definition.MerchantStore;
foreach (var potionNumber in GetLowPotionKinds(player))
{
var storeItem = store?.Items.FirstOrDefault(i => i.Definition?.Group == 14 && i.Definition.Number == potionNumber);
if (storeItem is null)
{
continue;
}
var boughtPotions = store is null ? 0 : await BuyPotionsAsync(player, store).ConfigureAwait(false);
var boughtJewels = store is null ? 0 : await BuyJewelsAsync(player, store).ConfigureAwait(false);
for (var i = 0; i < MaxPurchasesPerKind
&& GetPotionCharges(player, potionNumber) < PotionTargetCharges
&& player.Money > MinZenReserve; i++)
{
var moneyBefore = player.Money;
await BuyAction.BuyItemAsync(player, storeItem.ItemSlot).ConfigureAwait(false);
if (player.Money >= moneyBefore)
{
break; // purchase failed (no money / no space)
}
boughtCount++;
}
}
// Only now, with every purchase of this visit paid for, is it settled how much room the
// money limit really leaves: each Zen spent above buys back the chance to sell one more
// piece instead of destroying it.
var (soldLate, discarded) = await ClearUnsoldAsync(player, inventory, unsold).ConfigureAwait(false);
sold += soldLate;
// Logged even for a 0/0 visit: an audit must be able to tell "went and had nothing to
// do" from a silently failed trip.
// do" from a silently failed trip. Every counter reports what REALLY happened - a sale
// which the money limit refused is not a sale.
player.Logger.LogInformation(
"Bot '{Name}' traded with '{Merchant}': sold {Sold} item(s), bought {Bought} potion stack(s), {Money} zen left.",
"Bot '{Name}' traded with '{Merchant}': sold {Sold} item(s), discarded {Discarded}, repaired {Repaired}, bought {Potions} potion stack(s) and {Jewels} jewel(s), {Money} zen left.",
player.Name,
merchant.Definition.Designation,
soldCount,
boughtCount,
sold,
discarded,
repaired,
boughtPotions,
boughtJewels,
player.Money);
}
finally
@@ -182,64 +193,363 @@ internal static class BotShoppingHandler
}
/// <summary>
/// Gets the potion kinds (item numbers in group 14) whose stack is running low.
/// Collects the backpack items the bot has no use for. Not its potions, not the jewels within its
/// working stock, and not a piece it would put on - but everything else goes, treasures included.
/// Unlike a player, a bot cannot trade an excellent piece away, so hoarding one only silts up the
/// backpack until the loot pickup stops entirely.
/// </summary>
private static IEnumerable<byte> GetLowPotionKinds(Player player)
private static List<Item> GetSellableJunk(OfflinePlayer player, IStorage inventory)
{
if (GetPotionCharges(player, 3) < PotionLowThreshold)
var stockLimit = BotJewelHandler.GetStockLimit(player);
var keptJewels = new Dictionary<ItemIdentifier, int>();
var junk = new List<Item>();
foreach (var item in inventory.Items)
{
yield return 3; // Large Healing Potion
if (item.ItemSlot < InventoryConstants.EquippableSlotsCount
|| item.Definition is not { } definition
|| definition.IsAmmunition)
{
continue; // equipped, or an archer's arrows - selling those would disarm the bow.
}
var identifier = new ItemIdentifier(definition.Number, definition.Group);
if (HealingHandler.HealthPotionPriority.Contains(identifier)
|| HealingHandler.ManaPotionPriority.Contains(identifier))
{
continue; // the survival kit the offline AI drinks from.
}
if (BotJewelHandler.UsableJewels.Contains(identifier))
{
// Keep the working stock and sell the surplus, counting per kind while walking the
// backpack: asking "is the stock full?" for each jewel on its own would answer yes for
// every one of fifteen Souls at a limit of ten, and the bot would end up with none.
var kept = keptJewels.GetValueOrDefault(identifier);
if (kept < stockLimit)
{
keptJewels[identifier] = kept + 1;
continue;
}
junk.Add(item);
continue;
}
// Whatever the bot would wear stays: selling a piece it picked up as an upgrade one tick
// before it puts it on is pure loss.
if (!BotEquipmentHandler.IsUpgradeFor(player, item))
{
junk.Add(item);
}
}
if (GetPotionCharges(player, 6) < PotionLowThreshold)
return junk;
}
/// <summary>
/// Sells the junk, most valuable piece first, so that a bot which is close to the money limit still
/// captures as much of its loot as fits. What the limit refuses is handed back to the caller: it is
/// offered once more after the purchases of this visit have freed some room.
/// </summary>
private static async ValueTask<(int Sold, List<Item> Unsold)> SellJunkAsync(OfflinePlayer player, IStorage inventory)
{
var junk = GetSellableJunk(player, inventory)
.Select(i => (Item: i, Price: PriceCalculator.CalculateSellingPrice(i, i.Durability())))
.OrderByDescending(x => x.Price)
.ToList();
var sold = 0;
var unsold = new List<Item>();
foreach (var (item, _) in junk)
{
yield return 6; // Large Mana Potion
if (await SellAction.SellItemAsync(player, item.ItemSlot).ConfigureAwait(false))
{
sold++;
}
else
{
unsold.Add(item);
}
}
return (sold, unsold);
}
/// <summary>
/// Deals with what the money limit refused earlier. The purchases in between have spent Zen, so a
/// second attempt sells whatever now fits. Only what is still refused is destroyed - there is no
/// other way out of the backpack for it (a bot cannot trade, and dropping upgraded or excellent gear
/// is something no player can do either), and a wedged bot is worse.
/// Gear is only destroyed under slot pressure: a full wallet alone is no reason to burn loot, and it
/// may well be sellable again next visit. Jewel surplus is not given that benefit of the doubt - a
/// kind the bot cannot use at all, or the part above its stock limit, has no use to it whatsoever,
/// and waiting for slot pressure just parks it in the backpack for hours.
/// </summary>
private static async ValueTask<(int Sold, int Discarded)> ClearUnsoldAsync(OfflinePlayer player, IStorage inventory, List<Item> unsold)
{
if (unsold.Count == 0)
{
return (0, 0);
}
var maximumMoney = player.GameContext.Configuration.MaximumInventoryMoney;
var underPressure = IsUnderSlotPressure(inventory);
var sold = 0;
var discarded = 0;
foreach (var item in unsold)
{
if (await SellAction.SellItemAsync(player, item.ItemSlot).ConfigureAwait(false))
{
sold++;
continue;
}
if (underPressure || IsDeadWeight(item))
{
await player.DestroyInventoryItemAsync(item).ConfigureAwait(false);
discarded++;
}
}
if (discarded > 0)
{
player.Logger.LogInformation(
"Bot '{Name}' destroyed {Count} item(s) it could not sell: its money is at the maximum of {Maximum}.",
player.Name,
discarded,
maximumMoney);
}
return (sold, discarded);
}
/// <summary>
/// Repairs the equipped gear while the merchant dialog is open, which is what earns the NPC discount
/// (see <see cref="ItemPriceCalculator.CalculateRepairPrice"/>). Repairing in the field without a
/// dialog - what the MU Helper's own auto repair does, and why it stays off for bots - pays the full
/// price instead. The cost scales with the missing durability, so repairing on every visit costs
/// about the same as one big repair later, and never lets an item reach zero, where the price is
/// multiplied by the destroyed-item penalty on top.
/// </summary>
/// <returns>The number of items which were repaired.</returns>
private static async ValueTask<int> RepairGearAsync(OfflinePlayer player)
{
if (player.Inventory is not { } inventory)
{
return 0;
}
var damaged = new List<(byte Slot, long Price)>();
for (var slot = InventoryConstants.FirstEquippableItemSlotIndex; slot <= InventoryConstants.LastEquippableItemSlotIndex; slot++)
{
if (slot == InventoryConstants.PetSlot)
{
continue; // pets are repaired by the pet trainer, not here.
}
if (inventory.GetItem(slot) is { } item
&& item.Durability() < item.GetMaximumDurabilityOfOnePiece())
{
damaged.Add((slot, PriceCalculator.CalculateRepairPrice(item, true)));
}
}
// Cheapest first, and never spend down to nothing: repairing everything a bot owns can cost more
// than it has, and `RepairAllItemsAsync` would take the money for the first pieces and stop -
// leaving the rest at zero durability AND the bot too poor to shop again, which is a trap it
// cannot get out of, because the merchant trip is the only place its gear ever gets repaired.
var repaired = 0;
foreach (var (slot, price) in damaged.OrderBy(d => d.Price))
{
if (player.Money - price < MinZenReserve)
{
continue;
}
await RepairAction.RepairItemAsync(player, slot).ConfigureAwait(false);
if (inventory.GetItem(slot) is { } item
&& item.Durability() >= item.GetMaximumDurabilityOfOnePiece())
{
repaired++;
}
}
return repaired;
}
/// <summary>
/// Restocks the potions the offline AI actually drinks, buying the biggest stack the bot can afford
/// of the best kind the merchant offers. Merchants often carry the same potion as a small and a large
/// stack; taking the first match would buy the tiny one twenty times over.
/// </summary>
private static async ValueTask<int> BuyPotionsAsync(OfflinePlayer player, ItemStorage store)
{
var target = GetPotionStockTarget(player);
var bought = 0;
foreach (var priority in GetLowPotionKinds(player))
{
for (var i = 0; i < MaxPurchasesPerKind && GetCharges(player, priority) < target; i++)
{
if (FindBestOffer(player, store, priority) is not { } offer)
{
break; // the merchant has none of this kind, or none the bot can afford.
}
var moneyBefore = player.Money;
await BuyAction.BuyItemAsync(player, offer.ItemSlot).ConfigureAwait(false);
if (player.Money >= moneyBefore)
{
break; // purchase failed (no money / no space)
}
bought++;
}
}
return bought;
}
/// <summary>
/// Buys the jewels the bot can spend on its own gear, if the merchant happens to sell any. No stock
/// merchant does, so on a default configuration this simply never fires - it is here for servers
/// which put jewels into their shops, where it turns a bot's Zen into actual progress instead of
/// letting it pile up against the money limit.
/// </summary>
private static async ValueTask<int> BuyJewelsAsync(OfflinePlayer player, ItemStorage store)
{
var bought = 0;
var stockLimit = BotJewelHandler.GetStockLimit(player);
foreach (var identifier in BotJewelHandler.UsableJewels)
{
while (bought < MaxJewelPurchasesPerTrip
&& BotJewelHandler.CountInStock(player, identifier) < stockLimit
&& FindAffordableOffer(player, store, identifier, JewelPurchaseReserve) is { } offer)
{
var moneyBefore = player.Money;
await BuyAction.BuyItemAsync(player, offer.ItemSlot).ConfigureAwait(false);
if (player.Money >= moneyBefore)
{
break; // purchase failed (no money / no space)
}
bought++;
}
}
return bought;
}
/// <summary>
/// Gets the potion kinds whose stock is running low, as the priority lists the offline AI drinks by.
/// The charges are counted over the whole list, not per item number: a bot with a full stack of
/// medium healing potions is not out of healing just because it holds no large ones.
/// </summary>
private static IEnumerable<ItemIdentifier[]> GetLowPotionKinds(Player player)
{
var low = GetPotionStockTarget(player) * PotionLowThresholdPercent / 100;
if (GetCharges(player, HealingHandler.HealthPotionPriority) < low)
{
yield return HealingHandler.HealthPotionPriority;
}
if (GetCharges(player, HealingHandler.ManaPotionPriority) < low)
{
yield return HealingHandler.ManaPotionPriority;
}
}
private static int GetPotionCharges(Player player, byte potionNumber)
private static int GetPotionStockTarget(Player player)
=> BotFeaturePlugIn.GetConfiguration(player.GameContext)?.GetEffectivePotionStockCharges() ?? 60;
private static int GetCharges(Player player, ItemIdentifier[] kinds)
{
return (int)(player.Inventory?.Items
.Where(i => i.Definition?.Group == 14 && i.Definition.Number == potionNumber)
.Where(i => i.Definition is { } definition && kinds.Contains(new ItemIdentifier(definition.Number, definition.Group)))
.Sum(i => i.Durability) ?? 0);
}
/// <summary>
/// Sellable junk: unequipped gear in the backpack - not potions/ammunition and not jewels. An
/// excellent or ancient piece is junk too unless it is an upgrade the bot is about to wear:
/// unlike a player, a bot cannot trade its treasures away, so hoarding them only silts up the
/// backpack until the loot pickup stops entirely.
/// Finds the best offer for a potion list: the highest priority kind the merchant has and the bot can
/// afford, and of that kind the biggest stack - one purchase of a 255 charge stack beats twenty
/// purchases of a stack of three, in Zen spent per charge as well as in backpack slots used.
/// </summary>
private static bool IsSellableJunk(OfflinePlayer player, Item item)
private static Item? FindBestOffer(Player player, ItemStorage store, ItemIdentifier[] priority)
{
if (item.ItemSlot < InventoryConstants.EquippableSlotsCount
|| item.Definition is not { } definition)
foreach (var identifier in priority)
{
// No reserve for potions on purpose: they are what keeps the bot alive and hunting, so
// spending the last coin on them is right. Holding a reserve back here meant a bot which
// the repair had left just under it walked to the merchant and bought nothing at all.
if (FindAffordableOffer(player, store, identifier, 0) is { } offer)
{
return offer;
}
}
return null;
}
private static Item? FindAffordableOffer(Player player, ItemStorage store, ItemIdentifier identifier, int reserve)
{
return store.Items
.Where(i => Matches(i, identifier))
.Where(i => PriceCalculator.CalculateFinalBuyingPrice(i) + reserve <= player.Money)
.OrderByDescending(i => i.Durability)
.FirstOrDefault();
}
/// <summary>
/// Ranks a merchant by what the bot needs right now: the one which sells its potions wins while they
/// run low, otherwise a jewel seller is worth the walk. Without this a bot with a full potion stock
/// still always walked to the potion girl, past the merchant which had what it actually wanted.
/// </summary>
private static int ScoreMerchant(OfflinePlayer player, ItemStorage store)
{
var needsPotions = GetLowPotionKinds(player).Any();
var sellsPotions = SellsPotions(store);
var sellsJewels = BotJewelHandler.UsableJewels
.Any(identifier => store.Items.Any(i => Matches(i, identifier)));
var score = 0;
if (sellsPotions)
{
score += needsPotions ? 2 : 1;
}
if (sellsJewels)
{
score += needsPotions ? 1 : 2;
}
return score;
}
/// <summary>
/// A jewel which reached the junk list: either a kind the bot can never spend, or the part of a
/// usable kind above its stock limit. Unlike a piece of gear it has no second life - the bot cannot
/// wear it, craft with it or trade it - so when the money limit refuses the sale there is nothing
/// left to wait for.
/// </summary>
private static bool IsDeadWeight(Item item)
{
if (item.Definition is not { } definition)
{
return false;
}
if (definition.Group >= 12 || definition.IsAmmunition)
{
return false; // potions, jewels, scrolls, event items etc.
}
if (Valuables.Contains(new ItemIdentifier(definition.Number, definition.Group)))
{
return false;
}
var isTreasure = item.ItemOptions.Any(o => o.ItemOption?.OptionType == DataModel.Configuration.Items.ItemOptionTypes.Excellent)
|| item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0);
if (isTreasure)
{
return !BotEquipmentHandler.IsUpgradeFor(player, item);
}
return true;
var identifier = new ItemIdentifier(definition.Number, definition.Group);
return BotJewelHandler.UsableJewels.Contains(identifier)
|| BotJewelHandler.UnusableJewels.Contains(identifier);
}
private static bool SellsPotions(DataModel.Entities.ItemStorage store)
{
return store.Items.Any(i => i.Definition?.Group == 14 && i.Definition.Number is 3 or 6);
}
private static bool SellsPotions(ItemStorage store)
=> HealingHandler.HealthPotionPriority.Concat(HealingHandler.ManaPotionPriority)
.Any(identifier => store.Items.Any(i => Matches(i, identifier)));
private static bool Matches(Item item, ItemIdentifier identifier)
=> item.Definition is { } definition && identifier == new ItemIdentifier(definition.Number, definition.Group);
private static bool IsUnderSlotPressure(IStorage inventory)
=> inventory.FreeSlots.Count() < FreeSlotPressure;
}

View File

@@ -31,19 +31,16 @@ public class BotSkillProgressionPlugIn : ICharacterLevelUpPlugIn
private static readonly BotSkillProgressionPlugIn CatchUp = new();
/// <summary>
/// Applies the progression a bot is owed but has not spent, when it enters the world. Points are
/// otherwise only invested on a level-up, so a character which was given points while it was not
/// playing - a freshly generated one, or one whose level-up handler failed - would carry them around
/// unspent until its next level-up and fight with the strength of a much weaker character in the
/// meantime. Cheap: only a bot which actually holds points is progressed.
/// Applies the progression a bot is owed but has not received, when it enters the world. Both stat
/// points and skills are otherwise only handed out on a level-up, so anything a bot became entitled
/// to while it was not playing waits for its next one - and a bot at the maximum level has no next
/// one. That covers a freshly generated character, one whose level-up handler failed, and a bot which
/// qualifies for a skill it was not allowed to learn when it last levelled.
/// </summary>
/// <param name="player">The bot which entered the world.</param>
public static void CatchUpPendingProgress(Player player)
{
if (player.SelectedCharacter?.LevelUpPoints > 0)
{
CatchUp.CharacterLeveledUp(player);
}
CatchUp.CharacterLeveledUp(player);
}
/// <inheritdoc />

View File

@@ -5,6 +5,7 @@
namespace MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using MUnique.OpenMU.GameLogic.PlugIns;
@@ -114,7 +115,8 @@ public sealed class BuffHandler
}
var skillEntry = this._player.SkillList?.GetSkill((ushort)buffId);
if (skillEntry?.Skill?.MagicEffectDef is null)
if (skillEntry?.Skill?.MagicEffectDef is null
|| !this.CanCast(skillEntry.Skill))
{
continue;
}
@@ -132,6 +134,22 @@ public sealed class BuffHandler
return true;
}
/// <summary>
/// Whether the character currently meets the skill's own requirements, and can therefore cast it
/// at all. A character keeps its skills across a reset but not the level which unlocked them, so a
/// veteran back at level 12 still owns Swell Life, which asks for level 120.
/// <para>
/// Skipping it here is what keeps the whole helper running. A buff which targets the party goes
/// through the skill plugin, which refuses an unmet requirement deep inside and silently - and this
/// handler reports it as applied regardless. The buff step then ends every tick believing it had
/// just buffed, so the steps behind it, picking up loot and attacking, never ran at all: the
/// character stood in the world doing nothing, for good.
/// </para>
/// </summary>
/// <param name="skill">The skill to cast.</param>
private bool CanCast(Skill skill)
=> BotProgression.MeetsRequirements(skill, attribute => this._player.Attributes?[attribute]);
/// <summary>
/// Attempts to apply the buff to self and, if applicable, to party members.
/// </summary>

View File

@@ -23,6 +23,12 @@ public sealed class CombatHandler
private const byte DefaultRange = 1;
private const byte BowRange = 6;
/// <summary>Item group of the Horn of Fenrir, the pet behind <see cref="DamageType.Fenrir"/>.</summary>
private const byte FenrirItemGroup = 13;
/// <summary>Item number of the Horn of Fenrir within <see cref="FenrirItemGroup"/>.</summary>
private const short FenrirItemNumber = 37;
/// <summary>
/// See <see cref="IsSafeTarget"/>: the largest share of the bot's maximum health a single average
/// monster hit may take for the monster to count as safe. Sized so the bot survives several hits
@@ -66,6 +72,23 @@ public sealed class CombatHandler
/// <summary>After this many consecutive failed approaches the target counts as unreachable.</summary>
private const int MaxApproachFailures = 3;
/// <summary>
/// A skill scoring at least this share of the best score counts as equally good, and reach decides
/// between them. Deliberately narrow: it is meant to level out the flat bonus of comparable spells,
/// not to trade away a skill which is genuinely stronger.
/// </summary>
private const float EquivalentSkillScoreShare = 0.9f;
/// <summary>
/// How many monsters an area skill is credited with at most. A pack does make one worth more than a
/// single-target skill, but not without bound - the extra targets are usually spread over the area,
/// and not all of them are actually caught.
/// </summary>
private const int MaxScoredAreaTargets = 5;
/// <summary>The distance around the current target within which monsters count as one pack.</summary>
private const int AreaSkillClusterRange = 3;
private const short DrainLifeBaseSkillId = 214;
private const short DrainLifeStrengthenerSkillId = 458;
private const short DrainLifeMasterySkillId = 462;
@@ -90,6 +113,7 @@ public sealed class CombatHandler
private IAttackable? _currentTarget;
private int _nearbyMonsterCount;
private int _targetsAroundCurrent = 1;
private int _currentComboStep;
private int _skillCooldownTicks;
private int _approachFailures;
@@ -97,6 +121,7 @@ public sealed class CombatHandler
private DateTime _unreachableTargetUntilUtc = DateTime.MinValue;
private SkillEntry? _tickBestSkill;
private bool _tickBestSkillComputed;
private DateTime _engageAtUtc = DateTime.MinValue;
/// <summary>
@@ -370,6 +395,21 @@ public sealed class CombatHandler
/// for casters, curse for summoners) plus the strongest attack skill it has learned - enough to tell
/// apart "kills this monster at a reasonable pace" from "barely scratches it".
/// </summary>
/// <summary>
/// Counts the monsters standing close enough to the target to be caught by an area skill aimed at it.
/// This is what decides whether the bot swings around itself or picks its single-target skill - the
/// hunting range is far too wide an area to answer that: it holds every monster of the ground.
/// </summary>
private static int CountPackAround(IAttackable? target, List<IAttackable> targets)
{
if (target is null)
{
return 1;
}
return targets.Count(m => m.GetDistanceTo(target) <= AreaSkillClusterRange);
}
private static float GetAttackPower(Player player)
{
if (player.Attributes is not { } attributes)
@@ -418,6 +458,7 @@ public sealed class CombatHandler
}
this._player.Rotation = this._player.GetDirectionTo(target);
this._player.LastAttackUtc = DateTime.UtcNow;
if (skillEntry?.Skill is not { } skill)
{
@@ -478,10 +519,13 @@ public sealed class CombatHandler
// character's hunting efficiency, not a crowd to camouflage.
this._currentTarget = candidates.SelectRandom();
this._nearbyMonsterCount = targets.Count;
this._targetsAroundCurrent = CountPackAround(this._currentTarget, targets);
}
else
{
this._nearbyMonsterCount = this.GetAttackableTargetsInHuntingRange().Count();
var targets = this.GetAttackableTargetsInHuntingRange().ToList();
this._nearbyMonsterCount = targets.Count;
this._targetsAroundCurrent = CountPackAround(this._currentTarget, targets);
}
}
@@ -721,34 +765,92 @@ public sealed class CombatHandler
return null;
}
SkillEntry? best = null;
var bestDamage = 0;
var ridesFenrir = this.RidesFenrir();
var candidates = new List<(SkillEntry Entry, float Score)>();
foreach (var entry in skillList.Skills)
{
if (entry.Skill is not { } skill || skill.AttackDamage <= 0)
if (entry.Skill is not { } skill
|| !BotProgression.IsAttackSkill(skill)
|| BotProgression.IsCastleSiegeOnly(skill)
|| (BotProgression.RequiresPet(skill) && !ridesFenrir)
|| skill.Range == 0
// Same trap as the buffs: a character keeps its skills across a reset but not the level
// which unlocked them, and the cast is refused deep inside, silently. A single-target
// skill picked here would simply not go off - the basic attack only steps in when NO
// skill was selected, not when the selected one fails.
|| !BotProgression.MeetsRequirements(skill, attribute => this._player.Attributes?[attribute])
|| !this.HasEnoughResources(entry))
{
continue;
}
if (skill.SkillType is not (SkillType.DirectHit
or SkillType.AreaSkillAutomaticHits
or SkillType.AreaSkillExplicitHits
or SkillType.AreaSkillExplicitTarget))
{
continue;
}
if (skill.AttackDamage > bestDamage && this.HasEnoughResources(entry))
{
best = entry;
bestDamage = skill.AttackDamage;
}
candidates.Add((entry, this.ScoreSkill(skill)));
}
this._tickBestSkill = best;
return best;
if (candidates.Count == 0)
{
this._tickBestSkill = null;
return null;
}
// Among skills which are worth about the same, reach decides. The flat bonus of a skill is added
// to the character's own damage, so at a few thousand base damage the gap between the strongest
// spell and the second strongest is a rounding error - while three tiles of range are three tiles
// whether the character is level 20 or 400. This is what stopped every wizard from fighting at
// arm's length with Hellfire.
var bestScore = candidates.Max(c => c.Score);
return this._tickBestSkill = candidates
.Where(c => c.Score >= bestScore * EquivalentSkillScoreShare)
.OrderByDescending(c => c.Entry.Skill!.Range)
.ThenByDescending(c => c.Entry.Skill!.MasterDefinition is not null)
.ThenByDescending(c => c.Score)
.First()
.Entry;
}
/// <summary>
/// Estimates what one cast of the skill is worth right now: the damage of a single hit, times the
/// number of hits the skill performs, times the number of monsters an area skill would catch.
/// <see cref="Skill.AttackDamage"/> alone does not say it - it is a flat bonus added to the
/// character's base damage, so a skill can carry none at all and still be the strongest thing the
/// class owns, which is exactly the case for a Rage Fighter's four-hit skills.
/// </summary>
private float ScoreSkill(Skill skill)
{
var attributes = this._player.Attributes;
var baseDamage = skill.DamageType switch
{
DamageType.Wizardry => attributes?[Stats.MaximumWizBaseDmg] ?? 0,
DamageType.Curse => attributes?[Stats.MaximumCurseBaseDmg] ?? 0,
// Only reached when the character actually rides a Fenrir - the skill is filtered out
// otherwise, because this attribute is derived from the character's own stats and says
// nothing about whether the pet is there (see RidesFenrir).
DamageType.Fenrir => attributes?[Stats.FenrirBaseDmg] ?? 0,
_ => attributes?[Stats.MaximumPhysBaseDmg] ?? 0,
};
var perHit = baseDamage + skill.AttackDamage;
var hits = Math.Max((int)skill.NumberOfHitsPerAttack, 1);
var targets = BotProgression.IsAreaSkill(skill)
? Math.Clamp(this._targetsAroundCurrent, 1, MaxScoredAreaTargets)
: 1;
return perHit * hits * targets;
}
/// <summary>
/// Determines whether the character actually rides a Fenrir, which the skills reported by
/// <see cref="BotProgression.RequiresPet"/> need in order to be worth anything.
/// </summary>
private bool RidesFenrir()
=> this._player.Inventory?.GetItem(InventoryConstants.PetSlot) is
{
Durability: > 0.0,
Definition: { Group: FenrirItemGroup, Number: FenrirItemNumber },
};
/// <summary>
/// Evaluates whether the skill in the given slot should fire this tick.
/// </summary>

View File

@@ -18,12 +18,12 @@ using MUnique.OpenMU.Interfaces;
/// </summary>
public sealed class HealingHandler
{
/// <summary>Drink a mana potion once mana falls below this share, so casters can keep casting.</summary>
private const int ManaThresholdPercent = 30;
private static readonly ItemConsumeAction ConsumeAction = new();
private static readonly ItemIdentifier[] HealthPotionPriority =
/// <summary>
/// The health potions the offline player drinks, best first. Also the shopping list a bot restocks
/// from (see <c>BotShoppingHandler</c>): buying what it does not drink, or not buying what it does,
/// is how a bot ends up starving next to a full merchant.
/// </summary>
internal static readonly ItemIdentifier[] HealthPotionPriority =
[
ItemConstants.LargeHealingPotion,
ItemConstants.MediumHealingPotion,
@@ -31,13 +31,21 @@ public sealed class HealingHandler
ItemConstants.Apple,
];
private static readonly ItemIdentifier[] ManaPotionPriority =
/// <summary>
/// The mana potions the offline player drinks, best first. <see cref="HealthPotionPriority"/>.
/// </summary>
internal static readonly ItemIdentifier[] ManaPotionPriority =
[
ItemConstants.LargeManaPotion,
ItemConstants.MediumManaPotion,
ItemConstants.SmallManaPotion,
];
/// <summary>Drink a mana potion once mana falls below this share, so casters can keep casting.</summary>
private const int ManaThresholdPercent = 30;
private static readonly ItemConsumeAction ConsumeAction = new();
private readonly OfflinePlayer _player;
private readonly IMuHelperSettings? _config;

View File

@@ -117,7 +117,11 @@ public sealed class ItemPickupHandler
if (this._config.PickJewel && IsJewel(item))
{
return true;
// A human's helper takes every jewel - its owner trades, crafts or hoards them later. A bot
// has no later: it can only spend Bless, Soul and Life on its own gear, so a Jewel of Chaos
// or a stock-exceeding Soul is a backpack slot it never gets back.
return this._player.Account?.IsBot != true
|| Bots.BotJewelHandler.WantsMoreOf(this._player, item);
}
var isAncient = item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0);

View File

@@ -157,6 +157,15 @@ public class OfflinePlayer : Player
/// </summary>
internal bool HasRevengeIntent => this._revenge is not null;
/// <summary>
/// Gets or sets the time the character last struck something. It is the only honest answer to
/// "is this map paying off": whatever keeps a bot from fighting - monsters it may not engage,
/// grounds another bot empties first, a map its level opened but its body cannot handle - the
/// symptom is the same, and so is the remedy (see <see cref="Bots.BotNavigator"/>: move to easier
/// ground rather than walk between hunting grounds forever).
/// </summary>
internal DateTime LastAttackUtc { get; set; } = DateTime.UtcNow;
/// <summary>
/// Initializes the offline player by loading the account fresh from the database.
/// </summary>

View File

@@ -28,7 +28,8 @@ public class SellItemToNpcAction
/// </summary>
/// <param name="player">The player.</param>
/// <param name="slot">The slot.</param>
public async ValueTask SellItemAsync(Player player, byte slot)
/// <returns><c>True</c>, if the item was sold; otherwise, <c>false</c>.</returns>
public async ValueTask<bool> SellItemAsync(Player player, byte slot)
{
using var loggerScope = player.Logger.BeginScope(this.GetType());
var item = player.Inventory?.GetItem(slot);
@@ -36,37 +37,45 @@ public class SellItemToNpcAction
{
player.Logger.LogWarning("Player {0} requested to sell item at slot {1}, but item wasn't found.", player, slot);
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(false)).ConfigureAwait(false);
return;
return false;
}
if (player.OpenedNpc?.Definition.MerchantStore is null)
{
player.Logger.LogWarning("Player {0} requested to sell item at slot {1} to an npc, but no npc merchant store is currently opened.", player, slot);
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(false)).ConfigureAwait(false);
return;
return false;
}
if (item.Definition is null || (item.Definition.IsBoundToCharacter && (item.Definition.Durability == 0 || item.Durability > 0)))
{
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(false)).ConfigureAwait(false);
return;
return false;
}
await this.SellItemAsync(player, item).ConfigureAwait(false);
return await this.SellItemAsync(player, item).ConfigureAwait(false);
}
private async ValueTask SellItemAsync(Player player, Item item)
private async ValueTask<bool> SellItemAsync(Player player, Item item)
{
var sellingPrice = (int)this._itemPriceCalculator.CalculateSellingPrice(item, item.Durability());
player.Logger.LogDebug("Calculated selling price {0} for item {1}", sellingPrice, item);
if (player.TryAddMoney(sellingPrice))
if (!player.TryAddMoney(sellingPrice))
{
player.Logger.LogDebug("Sold Item {0} for price: {1}", item, sellingPrice);
await player.Inventory!.RemoveItemAsync(item).ConfigureAwait(false);
await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(true)).ConfigureAwait(false);
player.GameContext.PlugInManager.GetPlugInPoint<IItemSoldToMerchantPlugIn>()?.ItemSold(player, item, player.OpenedNpc!);
// The money doesn't fit into the inventory anymore. Without the answer the request would
// stay unanswered - the client keeps waiting, and the player gets no hint why nothing
// happened. All other refusals above already report back this way.
player.Logger.LogDebug("Item {0} not sold, the money of player {1} is at its maximum.", item, player);
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(false)).ConfigureAwait(false);
return false;
}
player.Logger.LogDebug("Sold Item {0} for price: {1}", item, sellingPrice);
await player.Inventory!.RemoveItemAsync(item).ConfigureAwait(false);
await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IItemSoldToNpcPlugIn>(p => p.ItemSoldToNpcAsync(true)).ConfigureAwait(false);
player.GameContext.PlugInManager.GetPlugInPoint<IItemSoldToMerchantPlugIn>()?.ItemSold(player, item, player.OpenedNpc!);
return true;
}
}

View 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)));
}
}

View File

@@ -35,12 +35,11 @@ public class BotJewelHandlerTest
var bless = await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfBless).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false);
var plan = BotJewelHandler.PlanNextUse(player, false);
var plan = BotJewelHandler.PlanNextUse(player, 2, 1, 1);
Assert.That(plan, Is.Not.Null);
Assert.That(plan!.Value.Jewel, Is.SameAs(bless));
Assert.That(plan.Value.Target, Is.SameAs(weakPiece));
Assert.That(plan.Value.IsLife, Is.False);
}
/// <summary>
@@ -59,7 +58,7 @@ public class BotJewelHandlerTest
await AddJewelAsync(player, (byte)(FirstBackpackSlot + i), ItemConstants.JewelOfSoul).ConfigureAwait(false);
}
var plan = BotJewelHandler.PlanNextUse(player, false);
var plan = BotJewelHandler.PlanNextUse(player, 2, 1, 1);
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
}
@@ -76,7 +75,7 @@ public class BotJewelHandlerTest
await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfSoul).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false);
var plan = BotJewelHandler.PlanNextUse(player, false);
var plan = BotJewelHandler.PlanNextUse(player, 2, 1, 1);
Assert.That(plan, Is.Not.Null);
Assert.That(plan!.Value.Target, Is.SameAs(luckyPiece));
@@ -100,7 +99,7 @@ public class BotJewelHandlerTest
await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfSoul).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false);
var plan = BotJewelHandler.PlanNextUse(player, false);
var plan = BotJewelHandler.PlanNextUse(player, 2, 1, 1);
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
}
@@ -116,19 +115,43 @@ public class BotJewelHandlerTest
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var target = await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 9).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfLife).ConfigureAwait(false);
var life = await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfLife).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfLife).ConfigureAwait(false);
var plan = BotJewelHandler.PlanNextUse(player, lifeAlreadyUsed);
var plan = BotJewelHandler.PlanNextUse(player, 2, 1, lifeAlreadyUsed ? 0 : 1);
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
if (expectsUse)
{
Assert.That(plan!.Value.Target, Is.SameAs(target));
Assert.That(plan.Value.IsLife, Is.True);
Assert.That(plan.Value.Jewel, Is.SameAs(life));
}
}
/// <summary>
/// Each jewel kind has its own budget: a spent Bless budget must not keep the Soul rule from
/// being reached. With one shared budget the Bless rule - which has a target whenever any equipped
/// piece is below +6 - consumed every use of every trip, and Souls and Lifes piled up unused.
/// </summary>
[Test]
public async ValueTask SpentBlessBudgetDoesNotStarveSoulAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
// A Bless target (below +6) and a Soul target (+6) are available at the same time.
await AddEquippedItemAsync(player, InventoryConstants.RightHandSlot, 2).ConfigureAwait(false);
var soulTarget = await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 6).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfBless).ConfigureAwait(false);
var soul = await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false);
await AddJewelAsync(player, FirstBackpackSlot + 2, ItemConstants.JewelOfSoul).ConfigureAwait(false);
var plan = BotJewelHandler.PlanNextUse(player, 0, 1, 1);
Assert.That(plan, Is.Not.Null);
Assert.That(plan!.Value.Jewel, Is.SameAs(soul));
Assert.That(plan.Value.Target, Is.SameAs(soulTarget));
}
/// <summary>
/// Without any applicable jewel or target, nothing is planned.
/// </summary>
@@ -138,7 +161,7 @@ public class BotJewelHandlerTest
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 2).ConfigureAwait(false);
var plan = BotJewelHandler.PlanNextUse(player, false);
var plan = BotJewelHandler.PlanNextUse(player, 2, 1, 1);
Assert.That(plan, Is.Null);
}

View File

@@ -87,6 +87,38 @@ public class BotServerPartitionTest
Assert.That(BotServerPartition.Split(capacities, 2, 150).Partition.IsGenerator, Is.False);
}
/// <summary>
/// A deployment which is configured to hold NO bots still has a server which generates - and, above
/// all, purges - the population. Deciding the role by the number of accounts instead would mean that
/// "delete all bots" does nothing at all on exactly the setting which asks for no bots.
/// </summary>
[Test]
public void TheFirstServerActsEvenWithoutAnyAccounts()
{
List<(byte ServerId, int Capacity)> capacities = [(0, 50), (1, 50)];
var (first, assigned) = BotServerPartition.Split(capacities, 0, 0);
var (second, _) = BotServerPartition.Split(capacities, 1, 0);
Assert.That(assigned, Is.EqualTo(0));
Assert.That(first.AccountCount, Is.EqualTo(0));
Assert.That(first.IsGenerator, Is.True);
Assert.That(second.IsGenerator, Is.False);
}
/// <summary>
/// The role is held by one server even when the deployment has no bot capacity left at all, so a
/// purge still finds someone to carry it out.
/// </summary>
[Test]
public void TheFirstServerActsWithoutAnyCapacity()
{
List<(byte ServerId, int Capacity)> capacities = [(0, 0), (1, 0)];
Assert.That(BotServerPartition.Split(capacities, 0, 100).Partition.IsGenerator, Is.True);
Assert.That(BotServerPartition.Split(capacities, 1, 100).Partition.IsGenerator, Is.False);
}
/// <summary>
/// The servers may have different player limits; the shares follow their capacity.
/// </summary>

View 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,
};
}
}