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

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