Merge pull request #820 from nolt/feature-bots

(cherry picked from commit b10de0645a869485fbb5771a739abd06b5708c2d)
This commit is contained in:
sven-n
2026-07-16 22:01:57 +02:00
committed by Acentech Dev
parent 8baf329654
commit d04cbecae3
58 changed files with 14856 additions and 62 deletions

View File

@@ -130,6 +130,13 @@ public class Account
/// </summary>
public bool IsTemplate { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this account is a server-side bot account.
/// Bot accounts are generated and maintained by the bot feature; this flag is the reliable
/// marker used to load them on startup (instead of regenerating) and to purge them.
/// </summary>
public bool IsBot { get; set; }
/// <summary>
/// Gets or sets the characters.
/// </summary>

View File

@@ -0,0 +1,122 @@
// <copyright file="BotConfiguration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// The admin-panel editable configuration of the <see cref="BotFeaturePlugIn"/>.
/// </summary>
public class BotConfiguration
{
/// <summary>
/// The hard limit of characters a single account can hold in the game.
/// </summary>
public const int MaxCharactersPerAccountLimit = 5;
/// <summary>
/// Gets or sets a value indicating whether the bot feature is enabled.
/// Disabled by default so that enabling bots is always an explicit, deliberate action.
/// </summary>
[Display(Name = "Enabled", Description = "If enabled, bots are spawned after the server has started.")]
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets a value indicating whether all bot accounts and characters should be deleted.
/// When set, the feature purges every bot account on the next startup before generating fresh
/// ones, and then automatically clears this flag again. Use it to reset the bot population.
/// </summary>
[Display(Name = "Reset bots", Description = "Deletes all bot accounts and characters on the next start, then regenerates them. Clears itself afterwards.")]
public bool ResetBots { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the bot population rotates its presence over the day:
/// fewer bots are online at night, most in the evening, with bots smoothly logging in and out -
/// like a real player base, instead of the same characters being online 24/7.
/// </summary>
[Display(Name = "Presence rotation", Description = "Bots log in and out over the day (fewest at night, most in the evening) instead of all being online 24/7.")]
public bool PresenceRotation { get; set; } = true;
/// <summary>
/// Gets or sets the share (in percent) of bots which stays online at the quietest time of day.
/// 100 effectively disables the rotation effect.
/// </summary>
[Display(Name = "Min. online share %", Description = "Percentage of the bot population which stays online at the quietest hour (100 = no rotation effect).")]
public int MinOnlineSharePercent { get; set; } = 60;
/// <summary>
/// Gets or sets the number of bot accounts. Together with <see cref="MaxCharactersPerAccount"/>
/// this defines the generated bot population, e.g. 10 accounts × 5 characters = 50 bot characters.
/// </summary>
[Display(Name = "Number of accounts", Description = "How many bot accounts to maintain.")]
[Range(0, 1000)]
public int NumberOfAccounts { get; set; } = 10;
/// <summary>
/// Gets or sets the number of characters per bot account. An account can hold at most
/// <see cref="MaxCharactersPerAccountLimit"/> (5) characters, so this value is clamped on use.
/// </summary>
[Display(Name = "Characters per account", Description = "How many characters each bot account holds (max 5).")]
[Range(1, MaxCharactersPerAccountLimit)]
public int MaxCharactersPerAccount { get; set; } = MaxCharactersPerAccountLimit;
/// <summary>
/// Gets or sets the share (in percent) of a game server's maximum player count which its bots may
/// occupy. Bots count towards that limit like players do, and a full server turns new clients away -
/// so the rest of the capacity stays reserved for real players, who must never be denied a slot by a
/// bot. The population is split over all configured game servers accordingly (see
/// <see cref="BotServerPartition"/>); accounts which do not fit stay offline until the servers offer
/// the room for them.
/// </summary>
[Display(Name = "Bot capacity %", Description = "Share of a game server's maximum player count which its bots may occupy; the rest stays reserved for real players.")]
[Range(1, 100)]
public int BotCapacityPercent { get; set; } = 60;
/// <summary>
/// Gets or sets a value indicating whether bots pay the configured reset costs (zen, reset items)
/// when they reset their character on a server with the reset feature enabled. Off by default:
/// bots don't take part in the player economy the costs are balanced for, so charging them only
/// stalls their progression (a bot can't farm zen for a billion-zen reset the way players trade).
/// </summary>
[Display(Name = "Bots pay reset costs", Description = "If enabled, bots consume the configured zen/item costs for their resets like human players (default: free bot resets).")]
public bool BotsPayResetCosts { get; set; }
/// <summary>
/// Gets or sets a comma separated list of login names of existing accounts to animate as bots.
/// This is an optional extra hook alongside the generated population (see
/// <see cref="NumberOfAccounts"/>): every listed account gets a bot driving its first character.
/// These accounts are animated as-is and are not part of the partitioned, capacity-limited
/// population, so leave it empty unless you specifically want to drive existing accounts.
/// </summary>
[Display(Name = "Extra accounts to animate", Description = "Comma separated login names of existing accounts to animate as bots, in addition to the generated population.")]
public string ProofOfConceptAccounts { get; set; } = string.Empty;
/// <summary>
/// Gets the effective, clamped number of characters per account.
/// </summary>
/// <returns>A value between 1 and <see cref="MaxCharactersPerAccountLimit"/>.</returns>
/// <remarks>Deliberately a method: a get-only property would end up in the serialized plugin configuration JSON.</remarks>
public int GetEffectiveCharactersPerAccount()
=> Math.Clamp(this.MaxCharactersPerAccount, 1, MaxCharactersPerAccountLimit);
/// <summary>
/// Gets the effective, clamped share of a server's player capacity which its bots may occupy.
/// </summary>
/// <returns>A value between 1 and 100.</returns>
/// <remarks>Deliberately a method, like <see cref="GetEffectiveCharactersPerAccount"/>.</remarks>
public int GetEffectiveBotCapacityPercent()
=> Math.Clamp(this.BotCapacityPercent, 1, 100);
/// <summary>
/// Parses <see cref="ProofOfConceptAccounts"/> into the distinct, trimmed login names.
/// </summary>
/// <returns>The list of login names.</returns>
/// <remarks>Deliberately a method: a get-only property would end up in the serialized plugin configuration JSON.</remarks>
public IReadOnlyList<string> ParseProofOfConceptAccounts()
=> this.ProofOfConceptAccounts
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}

View File

@@ -0,0 +1,283 @@
// <copyright file="BotEquipmentHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions.Items;
/// <summary>
/// Lets a bot progress its equipment like a real player: dropped gear is evaluated before pickup
/// (see <see cref="IsUpgradeFor"/>, used by the offline <see cref="ItemPickupHandler"/>), and looted
/// upgrades are periodically equipped; the replaced piece goes into the backpack and is sold on the next
/// shopping trip, so the backpack does not silt up with junk. All moves go through the regular
/// <see cref="MoveItemAction"/>, which enforces the same class- and stat-requirements as for a human
/// player - including which hand a weapon needs (see <see cref="ItemExtensions.ConflictsWithEquippedHands"/>).
/// </summary>
internal static class BotEquipmentHandler
{
/// <summary>A candidate must beat the equipped piece by at least this score margin to be worth swapping.</summary>
private const int UpgradeScoreMargin = 1;
/// <summary>The highest item group which is a weapon (0 sword, 1 axe, 2 mace, 3 spear, 4 bow, 5 staff).</summary>
private const byte LastWeaponGroup = 5;
/// <summary>The item group of the shields.</summary>
private const byte ShieldGroup = 6;
private static readonly MoveItemAction MoveAction = new();
/// <summary>
/// Determines whether the dropped item would be an upgrade over the bot's currently equipped gear,
/// so the pickup handler only collects items worth carrying.
/// </summary>
/// <param name="player">The bot player which would wear the item.</param>
/// <param name="item">The dropped item to evaluate.</param>
public static bool IsUpgradeFor(Player player, Item item)
{
return TryPlanSwap(player, item) is not null;
}
/// <summary>
/// Scans the bot's backpack for equippable upgrades and puts the best one on; the replaced piece
/// stays in the backpack, where the next shopping trip sells it.
/// </summary>
/// <param name="player">The bot player whose backpack is scanned.</param>
public static async ValueTask TryEquipUpgradesAsync(OfflinePlayer player)
{
if (player.Inventory is not { } inventory)
{
return;
}
// Snapshot, because equipping mutates the item collection while we iterate.
var backpackItems = inventory.Items
.Where(i => i.ItemSlot >= InventoryConstants.EquippableSlotsCount)
.ToList();
foreach (var item in backpackItems)
{
if (TryPlanSwap(player, item) is not { } plan)
{
continue;
}
if (await TryApplySwapAsync(player, inventory, item, plan).ConfigureAwait(false))
{
// One swap per pass keeps the work per tick small; the next pass picks up the rest.
return;
}
}
}
/// <summary>
/// Puts the planned piece on: the gear it replaces goes to the backpack first (an equip only works
/// into a free slot), then the candidate is equipped through the regular <see cref="MoveItemAction"/>.
/// If the engine refuses the equip after all - the requirements are checked against the TOTAL stats,
/// which drop as soon as the old piece with its bonuses comes off - everything moved so far is put
/// back on. Without that rollback the bot ended up with an empty slot, re-equipped the old piece on
/// its next pass and started over: hundreds of swaps per hour, fighting without a weapon half of the
/// time.
/// </summary>
/// <returns><c>true</c> if the candidate is now equipped.</returns>
private static async ValueTask<bool> TryApplySwapAsync(OfflinePlayer player, IStorage inventory, Item item, EquipSwap plan)
{
var undo = new List<(Item Item, byte EquipSlot)>(2);
foreach (var removed in plan.Removed)
{
if (inventory.CheckInvSpace(removed) is not { } freeSlot)
{
// No room of the item's SIZE in the backpack (a 2x3 armor needs a 2x3 hole).
await RollbackAsync(player, undo).ConfigureAwait(false);
return false;
}
var equipSlot = removed.ItemSlot;
await MoveAction.MoveItemAsync(player, equipSlot, Storages.Inventory, freeSlot, Storages.Inventory).ConfigureAwait(false);
if (inventory.GetItem(equipSlot) is not null)
{
player.Logger.LogDebug("Bot '{Name}' could not take off '{Item}' for a swap.", player.Name, removed);
await RollbackAsync(player, undo).ConfigureAwait(false);
return false;
}
undo.Add((removed, equipSlot));
}
await MoveAction.MoveItemAsync(player, item.ItemSlot, Storages.Inventory, plan.Slot, Storages.Inventory).ConfigureAwait(false);
if (inventory.GetItem(plan.Slot) != item)
{
player.Logger.LogDebug("Bot '{Name}' could not equip '{Item}' - putting its old gear back on.", player.Name, item);
await RollbackAsync(player, undo).ConfigureAwait(false);
return false;
}
player.Logger.LogInformation(
"Bot '{Name}' equipped '{New}'{Replaced}.",
player.Name,
item,
plan.Removed.Count == 0 ? string.Empty : $" (replacing {string.Join(", ", plan.Removed.Select(r => $"'{r}'"))})");
// The outgrown gear stays in the backpack and is SOLD on the next shopping trip (see
// BotShoppingHandler.IsSellableJunk): dropping it on the ground littered the hunting grounds
// with the bots' hand-me-downs, which the bots then picked up again - including the bot which
// had just dropped the piece, whose persistence context still tracked it (an entity conflict on
// the pickup). Selling it also feeds the Zen the bot restocks its potions with.
return true;
}
private static async ValueTask RollbackAsync(OfflinePlayer player, List<(Item Item, byte EquipSlot)> undo)
{
foreach (var (item, equipSlot) in undo)
{
await MoveAction.MoveItemAsync(player, item.ItemSlot, Storages.Inventory, equipSlot, Storages.Inventory).ConfigureAwait(false);
if (player.Inventory?.GetItem(equipSlot) != item)
{
player.Logger.LogWarning("Bot '{Name}' could not put '{Item}' back on into slot {Slot}.", player.Name, item, equipSlot);
}
}
}
/// <summary>
/// Plans how the item could be worn: into which slot, and which equipped pieces it would replace.
/// Returns <c>null</c> when the bot would not (or could not) wear it - which is exactly what makes an
/// item worth picking up from the ground, so the pickup handler asks the same question through
/// <see cref="IsUpgradeFor"/>. Beside the piece in the target slot, a two-handed weapon also replaces
/// whatever blocks the other hand: the engine refuses to equip it otherwise (see
/// <see cref="ItemExtensions.ConflictsWithEquippedHands"/>), and a bot which does not plan for that
/// keeps trying (and failing) to put it on forever.
/// </summary>
private static EquipSwap? TryPlanSwap(Player player, Item item)
{
if (item.Definition is not { } definition
|| player.SelectedCharacter?.CharacterClass is not { } characterClass
|| player.Inventory is not { } inventory
|| !IsWearableCandidate(player, definition, characterClass)
|| !player.CompliesRequirements(item))
{
return null;
}
var candidateScore = Score(item);
EquipSwap? best = null;
var bestReplacedScore = 0;
foreach (var slot in GetTargetSlots(definition))
{
var equipped = inventory.GetItem(slot);
if (equipped?.Definition?.IsAmmunition == true)
{
// Never displace the ammunition (an archer's arrows) - the bow would stop working.
continue;
}
var removed = new List<Item>(2);
if (equipped is not null)
{
removed.Add(equipped);
}
if (definition.ConflictsWithEquippedHands(inventory, slot))
{
// Only the main hand resolves such a conflict: taking the two-handed weapon off to fit a
// shield into the other hand would disarm the bot.
if (slot != InventoryConstants.LeftHandSlot
|| inventory.GetItem(InventoryConstants.RightHandSlot) is not { } blocking)
{
continue;
}
removed.Add(blocking);
}
var replacedScore = removed.Sum(Score);
if (removed.Count > 0 && replacedScore + UpgradeScoreMargin > candidateScore)
{
continue;
}
// Prefer the cheapest swap: an empty slot beats replacing gear, and among occupied slots the
// weakest gear goes first (this is what spreads rings over both ring slots).
if (best is null || replacedScore < bestReplacedScore)
{
best = new EquipSwap(slot, removed);
bestReplacedScore = replacedScore;
}
}
return best;
}
/// <summary>
/// The slots the bot considers for this piece: a weapon only goes into the main hand and a shield
/// only into the off-hand - a bot filling its off-hand with a second (junk) weapon it happens to be
/// qualified for is neither useful nor a sight any real character offers. Everything else may go into
/// any of its qualified slots (rings have two).
/// </summary>
private static IEnumerable<byte> GetTargetSlots(ItemDefinition definition)
{
var slots = definition.ItemSlot!.ItemSlots.Select(s => (byte)s).ToList();
if (definition.Group <= LastWeaponGroup && slots.Contains(InventoryConstants.LeftHandSlot))
{
return [InventoryConstants.LeftHandSlot];
}
if (definition.Group == ShieldGroup && slots.Contains(InventoryConstants.RightHandSlot))
{
return [InventoryConstants.RightHandSlot];
}
return slots;
}
/// <summary>
/// Whether the item is gear this bot would wear at all: an equippable, class-qualified piece which -
/// if it is a weapon - matches the fighting style of the bot's build (an elf only considers bows, a
/// caster only staves), so bots don't fill their hands with random qualified junk like a Small Axe.
/// </summary>
private static bool IsWearableCandidate(Player player, ItemDefinition definition, CharacterClass characterClass)
{
if (definition.ItemSlot is not { ItemSlots.Count: > 0 }
|| definition.IsAmmunition
|| !definition.QualifiedCharacters.Contains(characterClass))
{
return false;
}
if (definition.Group > LastWeaponGroup)
{
return true;
}
var resetMeta = BotResetHandler.GetResetConfiguration(player.GameContext) is not null;
return BotProgression.IsPreferredWeaponGroup(characterClass, player.SelectedCharacter!.Name, resetMeta, (byte)definition.Group);
}
/// <summary>
/// A rough, monotonic quality score of an equippable item: the definition's drop level tracks the
/// gear tier, the item level its upgrades, and excellent/ancient options add their extra worth.
/// </summary>
private static int Score(Item item)
{
if (item.Definition is not { } definition)
{
return 0;
}
var score = definition.DropLevel + (item.Level * 3);
score += 12 * item.ItemOptions.Count(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent);
if (item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0))
{
score += 15;
}
return score;
}
/// <summary>
/// A planned equip: the slot the candidate goes into, and the equipped pieces which have to come off
/// for it (the gear in the target slot, plus the other hand's item when a two-handed weapon needs it).
/// </summary>
private sealed record EquipSwap(byte Slot, IReadOnlyList<Item> Removed);
}

View File

@@ -0,0 +1,576 @@
// <copyright file="BotFeaturePlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Collections.Concurrent;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Feature plugin which spawns and maintains server-side bots.
/// Appears in the "Feature Plugins" section of the admin panel next to the MU Helper and reset features.
/// </summary>
[PlugIn]
[Display(Name = "Bots", Description = "Spawns server-side bots which hunt monsters on the maps. Configure the accounts to animate and enable the feature.")]
[Guid("6F3A2B91-7C4E-4D88-9A1F-2E5C0B7A4D63")]
public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCustomConfiguration<BotConfiguration>, ISupportDefaultCustomConfiguration
{
/// <summary>
/// Delay before the first spawn attempt, giving the server time to finish starting up
/// (maps, configuration and plugins fully initialized).
/// </summary>
private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15);
private static readonly TimeSpan MaintenanceInterval = TimeSpan.FromSeconds(60);
private static readonly TimeSpan PartyReformInterval = TimeSpan.FromMinutes(60);
/// <summary>
/// The typical activity of a player base by local hour (0..1): quietest in the early morning,
/// busiest in the evening. Scales between <see cref="BotConfiguration.MinOnlineSharePercent"/>
/// and 100% of the bot population.
/// </summary>
private static readonly double[] ActivityByHour =
[
0.30, 0.15, 0.05, 0.00, 0.00, 0.05, 0.10, 0.20, 0.30, 0.35, 0.40, 0.45,
0.50, 0.50, 0.55, 0.60, 0.70, 0.80, 0.90, 1.00, 1.00, 0.95, 0.80, 0.50,
];
/// <summary>
/// The state of the feature, per game server: the plugin instance is shared by all game servers of
/// the process, while <see cref="ExecuteTaskAsync"/> is called by each of them separately. One shared
/// state would mean the server whose timer fires first animates the whole population (which is how
/// the bots used to end up on a single server), and the other servers doing nothing at all.
/// </summary>
private readonly ConcurrentDictionary<IGameContext, ServerState> _states = new();
/// <summary>
/// The phase of a game server's single startup pass. The periodic task timer fires every second
/// WITHOUT awaiting the previous invocation, so during the minutes-long generation/spawn further
/// ticks arrive concurrently - they must neither re-enter the startup nor run the maintenance
/// (e.g. the presence rotation) against a half-spawned population.
/// </summary>
private enum StartupPhase
{
/// <summary>The startup has not run yet.</summary>
NotStarted = 0,
/// <summary>The startup (generation and spawn) is in progress.</summary>
InProgress = 1,
/// <summary>The startup is done; the server is in maintenance mode.</summary>
Done = 2,
}
/// <inheritdoc />
public BotConfiguration? Configuration { get; set; }
/// <inheritdoc />
public async ValueTask ExecuteTaskAsync(GameContext gameContext)
{
var state = this._states.GetOrAdd(gameContext, _ => new ServerState());
if (state.StartupState == (int)StartupPhase.Done)
{
await this.RunMaintenanceAsync(gameContext, state).ConfigureAwait(false);
return;
}
if (DateTime.UtcNow < state.NextRunUtc
|| Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.NotStarted) != (int)StartupPhase.NotStarted)
{
return;
}
var configuration = this.Configuration ??= CreateDefaultConfiguration();
if (!configuration.Enabled)
{
// Not spawned - re-check on the following ticks, the feature may get enabled later.
Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.NotStarted);
return;
}
try
{
await this.SpawnPopulationAsync(gameContext, state, configuration).ConfigureAwait(false);
}
finally
{
// Like before: the startup runs once, even when parts of it failed (the errors are
// logged); the maintenance pass takes over from here.
Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.Done);
}
}
private async ValueTask SpawnPopulationAsync(GameContext gameContext, ServerState state, BotConfiguration configuration)
{
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
using var scope = logger.BeginScope(gameContext);
var generator = new BotGenerator(gameContext, logger);
var partition = state.Partition = await BotServerPartition.CreateAsync(gameContext, configuration, logger).ConfigureAwait(false);
if (configuration.ResetBots && partition.IsGenerator)
{
try
{
var deleted = await generator.DeleteAllBotsAsync().ConfigureAwait(false);
logger.LogInformation("Reset requested: purged {Deleted} bot account(s).", deleted);
// Clear the flag (in memory and persisted) so the next restart does not purge again.
configuration.ResetBots = false;
await this.PersistConfigurationAsync(gameContext, configuration, logger).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reset the bot population.");
}
}
if (partition.IsGenerator)
{
try
{
// Generate the persistent bot population if it is not there yet (idempotent). Only this
// server does it - see BotServerPartition.IsGenerator; the others find the accounts once
// they exist and retry the spawns of their own share meanwhile.
var created = await generator.EnsureBotsAsync(configuration.NumberOfAccounts, configuration.MaxCharactersPerAccount).ConfigureAwait(false);
if (created > 0)
{
logger.LogInformation("Generated {Created} new bot account(s).", created);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to generate the bot population.");
}
}
var charactersPerAccount = Math.Clamp(
Math.Min(configuration.MaxCharactersPerAccount, gameContext.Configuration.MaximumCharactersPerAccount),
1,
BotConfiguration.MaxCharactersPerAccountLimit);
var started = 0;
var total = 0;
for (var i = partition.FirstAccount; i < partition.FirstAccount + partition.AccountCount; i++)
{
var loginName = BotGenerator.GetLoginName(i);
for (byte slot = 0; slot < charactersPerAccount; slot++)
{
total++;
try
{
if (await state.Manager.SpawnBotAsync(gameContext, loginName, slot).ConfigureAwait(false))
{
started++;
}
else
{
// The account may just not be generated yet (another server is generating the
// population right now) - the maintenance pass retries it.
state.PendingRespawns.Enqueue((loginName, slot));
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to spawn bot for account '{LoginName}' (slot {Slot}).", loginName, slot);
}
}
}
// The proof-of-concept accounts remain an optional extra hook to animate existing (non-bot)
// accounts. They are not part of the partitioned population, so only one server animates them.
if (partition.IsGenerator)
{
foreach (var loginName in configuration.ParseProofOfConceptAccounts())
{
try
{
if (await state.Manager.SpawnBotAsync(gameContext, loginName).ConfigureAwait(false))
{
started++;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to spawn proof-of-concept bot for account '{LoginName}'.", loginName);
}
}
}
logger.LogInformation("Bot feature started {Started} of {Total} bots.", started, total);
try
{
await state.Manager.FormPartiesAsync(gameContext).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to form bot parties.");
}
}
/// <inheritdoc />
public void ForceStart()
{
foreach (var state in this._states.Values)
{
state.NextRunUtc = DateTime.UtcNow;
}
}
/// <inheritdoc />
public object CreateDefaultConfig()
{
return CreateDefaultConfiguration();
}
private static BotConfiguration CreateDefaultConfiguration()
{
return new BotConfiguration();
}
/// <summary>
/// Runs the periodic post-spawn maintenance: the presence rotation (one bot in or out per pass, so
/// the population ebbs and flows smoothly over the day) and an hourly party re-formation which
/// groups bots that lost or never had a party (e.g. after rotating back in).
/// </summary>
private async ValueTask RunMaintenanceAsync(GameContext gameContext, ServerState state)
{
if (DateTime.UtcNow < state.NextMaintenanceUtc)
{
return;
}
// The engine fires the periodic tasks every second WITHOUT awaiting the previous run, so a pass
// which takes longer than its interval (spawning a bot loads a whole account from the database)
// would otherwise overlap with itself: two passes restarting the same bot, rotating the presence
// twice, forming parties in parallel. One pass at a time - like the startup below.
if (Interlocked.CompareExchange(ref state.MaintenanceRunning, 1, 0) != 0)
{
return;
}
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name);
try
{
state.NextMaintenanceUtc = DateTime.UtcNow + MaintenanceInterval;
var configuration = this.Configuration;
if (configuration?.Enabled != true)
{
return;
}
await this.RespawnPendingAsync(gameContext, state).ConfigureAwait(false);
await this.RestartFaultedBotsAsync(gameContext, state, logger).ConfigureAwait(false);
await this.EvolveDueMastersAsync(gameContext, state, logger).ConfigureAwait(false);
if (configuration.PresenceRotation)
{
await this.RotatePresenceAsync(gameContext, state, configuration, logger).ConfigureAwait(false);
}
if (DateTime.UtcNow >= state.NextPartyReformUtc)
{
state.NextPartyReformUtc = DateTime.UtcNow + PartyReformInterval;
await state.Manager.FormPartiesAsync(gameContext).ConfigureAwait(false);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Bot maintenance failed.");
}
finally
{
Interlocked.Exchange(ref state.MaintenanceRunning, 0);
}
}
/// <summary>
/// Restarts bots whose AI keeps throwing (see <see cref="BotPlayer.AwaitsFaultRestart"/>). The
/// engine's attribute system is not thread-safe, and a lost race can corrupt a character's attribute
/// graph for good: the bot stops playing and every following tick throws the same exception, up to a
/// flood of them per second. A fresh login rebuilds the graph and heals it - the same thing a player
/// would do, and the only cure available from outside the engine. Runs from the maintenance pass,
/// which is the only place allowed to restart a bot.
/// </summary>
private async ValueTask RestartFaultedBotsAsync(GameContext gameContext, ServerState state, ILogger logger)
{
foreach (var bot in state.Manager.Bots)
{
if (!bot.AwaitsFaultRestart)
{
continue;
}
var loginName = bot.Account?.LoginName;
var characterSlot = bot.SelectedCharacter?.CharacterSlot;
bot.AwaitsFaultRestart = false;
try
{
if (!await state.Manager.RestartBotAsync(gameContext, bot).ConfigureAwait(false)
&& loginName is not null
&& characterSlot is { } slot)
{
state.PendingRespawns.Enqueue((loginName, slot));
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to restart the faulted bot '{Name}'.", bot.Name);
}
}
}
/// <summary>
/// Evolves bots which reached the game's maximum level into their master class (see
/// <see cref="BotMasterHandler"/> for the rules, including the iron rule of reset servers).
/// Runs from the maintenance pass - outside the bot's own AI tick - because the evolved bot is
/// restarted right away (see <see cref="BotManager.RestartBotAsync"/>), which must not happen
/// from within one of its own timer callbacks.
/// </summary>
private async ValueTask EvolveDueMastersAsync(GameContext gameContext, ServerState state, ILogger logger)
{
foreach (var bot in state.Manager.Bots)
{
// Not while it hunts with a human (the restart would desert the group), sits in an NPC
// dialog or lies dead - like a due reset, the evolution simply happens on a later pass.
if (BotPartyHandler.HasHumanCompanion(bot)
|| bot.PlayerState.CurrentState != PlayerState.EnteredWorld
|| !bot.IsAlive)
{
continue;
}
if (bot.AwaitsMasterRestart)
{
await this.RestartEvolvedBotAsync(gameContext, state, bot, logger).ConfigureAwait(false);
continue;
}
if (BotMasterHandler.IsMasterEvolutionDue(bot))
{
// The class change and its save go through the bot's own tick, like every other
// bot-initiated mutation (see OfflinePlayer.PendingBotActions): running them from the
// maintenance pass would write the character through the same persistence context the
// combat tick is using at that very moment. The restart follows on the next pass -
// it must NOT happen from within one of the bot's own timer callbacks.
bot.PendingBotActions.Enqueue(async () =>
{
if (await BotMasterHandler.TryEvolveAsync(bot).ConfigureAwait(false))
{
bot.AwaitsMasterRestart = true;
}
});
}
}
}
/// <summary>
/// Gives the freshly evolved bot the "relog" it needs: the master class's base attributes (master
/// experience rate, master points per level) and the master level stat are only mounted when a
/// character enters the world (see <see cref="BotManager.RestartBotAsync"/>).
/// </summary>
private async ValueTask RestartEvolvedBotAsync(GameContext gameContext, ServerState state, BotPlayer bot, ILogger logger)
{
// Captured before the restart - the disposed bot loses its account and character.
var loginName = bot.Account?.LoginName;
var characterSlot = bot.SelectedCharacter?.CharacterSlot;
bot.AwaitsMasterRestart = false;
try
{
if (!await state.Manager.RestartBotAsync(gameContext, bot).ConfigureAwait(false)
&& loginName is not null
&& characterSlot is { } slot)
{
// The evolution is persisted; only the presence is at risk. RespawnPendingAsync
// drops the entry when the bot is (still or again) online, so a kept-alive old
// instance or a rotation comeback doesn't get doubled.
logger.LogWarning("Evolved bot '{Name}' could not be respawned right away; retrying on the next pass.", bot.Name);
state.PendingRespawns.Enqueue((loginName, slot));
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to restart bot '{Name}' after its master evolution.", bot.Name);
}
}
/// <summary>
/// Retries bringing back bots whose respawn after the master evolution failed.
/// </summary>
private async ValueTask RespawnPendingAsync(GameContext gameContext, ServerState state)
{
var count = state.PendingRespawns.Count;
for (var i = 0; i < count && state.PendingRespawns.TryDequeue(out var entry); i++)
{
if (state.Manager.IsActive(entry.Login, entry.Slot))
{
continue;
}
if (!await state.Manager.SpawnBotAsync(gameContext, entry.Login, entry.Slot).ConfigureAwait(false))
{
state.PendingRespawns.Enqueue(entry);
}
}
}
/// <summary>
/// Rotates the presence of the bots THIS server animates (see <see cref="BotServerPartition"/>):
/// each server keeps the daily curve within its own share of the population.
/// </summary>
private async ValueTask RotatePresenceAsync(GameContext gameContext, ServerState state, BotConfiguration configuration, ILogger logger)
{
if (state.Partition is not { AccountCount: > 0 } partition)
{
return;
}
var charactersPerAccount = configuration.GetEffectiveCharactersPerAccount();
var totalPopulation = partition.AccountCount * charactersPerAccount;
if (totalPopulation <= 0)
{
return;
}
var minShare = Math.Clamp(configuration.MinOnlineSharePercent, 0, 100) / 100.0;
// Local wall-clock time on purpose (the rest of the class uses UtcNow for durations): this curve
// models human presence - fewest in the early morning, most in the evening - so it has to follow
// the players' day, which is the host's local time, not UTC. On a UTC-configured host the two
// coincide; on a host set to the player base's zone, local time keeps the peak in their evening.
var activity = ActivityByHour[DateTime.Now.Hour];
var targetOnline = (int)Math.Round(totalPopulation * (minShare + ((1.0 - minShare) * activity)));
var online = state.Manager.Bots.Count;
if (online < targetOnline)
{
// Bring one bot online: pick a random character which is currently offline.
var offline = new List<(string Login, byte Slot)>();
for (var i = partition.FirstAccount; i < partition.FirstAccount + partition.AccountCount; i++)
{
var loginName = BotGenerator.GetLoginName(i);
for (byte slot = 0; slot < charactersPerAccount; slot++)
{
if (!state.Manager.IsActive(loginName, slot))
{
offline.Add((loginName, slot));
}
}
}
if (offline.SelectRandom() is { Login: not null } candidate
&& await state.Manager.SpawnBotAsync(gameContext, candidate.Login, candidate.Slot).ConfigureAwait(false))
{
logger.LogInformation("Bot presence rotation: +1 (online {Online}/{Target} of {Total}).", online + 1, targetOnline, totalPopulation);
}
}
else if (online > targetOnline)
{
var stopped = await state.Manager.StopRandomBotAsync().ConfigureAwait(false);
if (stopped is not null)
{
logger.LogInformation("Bot presence rotation: -1 '{Name}' (online {Online}/{Target} of {Total}).", stopped, online - 1, targetOnline, totalPopulation);
}
}
}
/// <summary>
/// Persists the current configuration back to its <see cref="PlugInConfiguration"/> row, so a
/// programmatic change (e.g. clearing the reset flag) survives a restart.
/// </summary>
private async ValueTask PersistConfigurationAsync(GameContext gameContext, BotConfiguration configuration, ILogger logger)
{
try
{
// Load the configuration through a fresh context so the PlugInConfiguration entity is tracked
// and the change is actually persisted - the in-memory cached config graph is not tracked.
using var context = gameContext.PersistenceContextProvider.CreateNewContext();
var typeId = typeof(BotFeaturePlugIn).GUID;
var gameConfiguration = (await context.GetAsync<GameConfiguration>().ConfigureAwait(false)).FirstOrDefault();
var entity = gameConfiguration?.PlugInConfigurations.FirstOrDefault(c => c.TypeId == typeId);
if (entity is null)
{
logger.LogWarning("Could not find the bot plugin configuration row to persist.");
return;
}
entity.SetConfiguration(configuration, gameContext.PlugInManager.CustomConfigReferenceHandler);
await context.SaveChangesAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to persist the bot plugin configuration.");
}
}
/// <summary>
/// The bot feature's state of ONE game server: its own share of the population, its own bots, and
/// its own startup and maintenance schedule (see <see cref="_states"/>).
/// </summary>
private sealed class ServerState
{
/// <summary>
/// The <see cref="StartupPhase"/> of this server, as a raw <see cref="int"/>: it is transitioned
/// with <see cref="Interlocked"/>, which has no overload for arbitrary enum types, so the field
/// stays an <see cref="int"/> and the phase constants are cast at the call sites.
/// </summary>
private int _startupState;
/// <summary>
/// 1 while a maintenance pass runs, so the next timer tick doesn't start a second one in
/// parallel. Interlocked-updated, hence a field.
/// </summary>
private int _maintenanceRunning;
/// <summary>
/// Gets the bots this server animates.
/// </summary>
public BotManager Manager { get; } = new();
/// <summary>
/// Gets the bots whose spawn failed - the account may not be generated yet (another game server
/// is generating the population), or a respawn after the master evolution did not go through.
/// Retried on the following maintenance passes.
/// </summary>
public ConcurrentQueue<(string Login, byte Slot)> PendingRespawns { get; } = new();
/// <summary>
/// Gets or sets the share of the bot population which this server animates.
/// </summary>
public BotServerPartition? Partition { get; set; }
/// <summary>
/// Gets or sets the time of this server's (single) startup pass.
/// </summary>
public DateTime NextRunUtc { get; set; } = DateTime.UtcNow + StartupDelay;
/// <summary>
/// Gets or sets the time of this server's next maintenance pass.
/// </summary>
public DateTime NextMaintenanceUtc { get; set; } = DateTime.UtcNow + StartupDelay + StartupDelay;
/// <summary>
/// Gets or sets the time of this server's next bot party re-formation.
/// </summary>
public DateTime NextPartyReformUtc { get; set; } = DateTime.UtcNow + PartyReformInterval;
/// <summary>
/// Gets a reference to the startup state, for the interlocked transitions of the startup pass.
/// </summary>
public ref int StartupState => ref this._startupState;
/// <summary>
/// Gets a reference to the maintenance flag, for the interlocked guard of the maintenance pass.
/// </summary>
public ref int MaintenanceRunning => ref this._maintenanceRunning;
}
}

View File

@@ -0,0 +1,606 @@
// <copyright file="BotGenerator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Linq;
using System.Threading;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Resets;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Generates and maintains the persistent population of bot accounts and their characters.
/// </summary>
/// <remarks>
/// Accounts are flagged with <see cref="Account.IsBot"/> so they can be reliably reloaded on
/// startup (instead of being regenerated) and purged on request. The account login names follow
/// a deterministic, internal scheme (<see cref="GetLoginName"/>) which is never shown to other
/// players; the player-visible character names are realistic and unique (see <see cref="BotNameGenerator"/>).
/// Generation is idempotent: only the missing accounts are created, so it is safe to run on every start.
/// </remarks>
internal sealed class BotGenerator
{
private const string LoginPrefix = "bot";
/// <summary>
/// BCrypt work factor for a bot account's password. The password is a random <see cref="Guid"/>
/// which is discarded immediately and never used to log in - a bot is a connection-less
/// <c>OfflinePlayer</c>, so no client ever authenticates against it. A minimal factor is therefore
/// safe (a 128-bit random secret is infeasible to brute-force regardless of the factor) and keeps
/// generating a large population from becoming a multi-minute BCrypt bottleneck, while still storing
/// a valid BCrypt hash. The default factor is kept for real accounts.
/// </summary>
private const int BotPasswordWorkFactor = 4;
private const int MinLevel = 10;
/// <summary>
/// The highest generated level. High enough that the upper maps (Tarkan, Aida, Kanturu, ...) get a
/// resident bot population and that some bots start beyond the class evolution level
/// (<see cref="BotProgression.ClassEvolutionLevel"/>) - those are created as their second-generation
/// class right away, like a player who did the class quest long ago.
/// </summary>
private const int MaxLevel = 250;
/// <summary>
/// Skew of the level distribution: values above 1 make low and mid levels more common than high
/// ones, like a real server's population pyramid (an even spread would feel top-heavy).
/// </summary>
private const double LevelSkew = 1.6;
private const int StartMoney = 100000;
/// <summary>Upgrade level (+6) of the starter gear, giving fresh bots a survival buffer until they can warp.</summary>
private const byte StarterItemLevel = 6;
/// <summary>Number of inventory extensions (each 4 rows of 8 slots) a bot gets, so loot does not clog its backpack.</summary>
private const int BotInventoryExtensions = 4;
/// <summary>Highest item group that is a melee weapon (0 sword, 1 axe, 2 mace, 3 spear).</summary>
private const byte MaxMeleeGroup = 3;
/// <summary>Item group of bows (need ammunition).</summary>
private const byte BowGroup = 4;
/// <summary>Item group of staves/sticks (casters).</summary>
private const byte StaffGroup = 5;
/// <summary>Item group of body armor; its item number identifies the armor set.</summary>
private const byte ArmorGroup = 8;
/// <summary>
/// Armor set numbers tried in thematic order; the first the class is qualified for (by its chest piece)
/// is used: 5 Leather (warriors), 2 Pad (wizards), 10 Vine (elves), 39 Mistery (summoners), then fallbacks.
/// </summary>
private static readonly byte[] ArmorSetCandidates = { 5, 2, 10, 39, 6, 0, 4, 8 };
private readonly IGameContext _gameContext;
private readonly ILogger _logger;
private readonly BotNameGenerator _nameGenerator = new();
/// <summary>
/// Initializes a new instance of the <see cref="BotGenerator"/> class.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="logger">The logger.</param>
public BotGenerator(IGameContext gameContext, ILogger logger)
{
this._gameContext = gameContext;
this._logger = logger;
}
/// <summary>
/// Gets the deterministic, internal login name of the bot account with the given one-based index.
/// </summary>
/// <param name="index">The one-based account index.</param>
/// <returns>The login name, e.g. <c>bot0001</c> (kept within the 10 character account name limit).</returns>
public static string GetLoginName(int index) => $"{LoginPrefix}{index:D4}";
/// <summary>
/// Ensures that the configured number of bot accounts (each with the configured number of
/// characters) exists. Only missing accounts are created.
/// </summary>
/// <param name="numberOfAccounts">The desired number of bot accounts.</param>
/// <param name="charactersPerAccount">The desired number of characters per account.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of accounts that were newly created.</returns>
public async ValueTask<int> EnsureBotsAsync(int numberOfAccounts, int charactersPerAccount, CancellationToken cancellationToken = default)
{
var creatableClasses = this._gameContext.Configuration.CharacterClasses
.Where(c => c is { CanGetCreated: true, HomeMap: not null })
.ToList();
if (creatableClasses.Count == 0)
{
this._logger.LogWarning("No creatable character classes found - cannot generate bots.");
return 0;
}
var perAccount = Math.Clamp(
Math.Min(charactersPerAccount, this._gameContext.Configuration.MaximumCharactersPerAccount),
1,
BotConfiguration.MaxCharactersPerAccountLimit);
var experienceTable = this._gameContext.ExperienceTable;
var maxLevel = Math.Min(MaxLevel, experienceTable.Length - 1);
var minLevel = Math.Clamp(MinLevel, 1, maxLevel);
// On servers with the reset feature the existing population has resets, so freshly generated
// bots get a random reset history too - a visitor should meet believable veterans (even TOP,
// max-reset characters), not a population uniformly starting from zero. Only possible when the
// configuration bounds the resets; unlimited-reset servers keep unseeded bots.
var resetConfiguration = BotResetHandler.GetResetConfiguration(this._gameContext);
var maxSeededResets = resetConfiguration?.ResetLimit is > 0 ? resetConfiguration.ResetLimit.Value : 0;
using var context = this._gameContext.PersistenceContextProvider.CreateNewPlayerContext(this._gameContext.Configuration);
var reservedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var created = 0;
// Build a balanced, shuffled queue of classes so the whole population is evenly split across
// all creatable classes. Independent random draws leave visible skew at this scale (e.g. 11
// Summoners vs 4 Elves for 50 bots); the quota queue guarantees ~even counts, drawn per character.
var classQueue = BuildBalancedClassQueue(creatableClasses, numberOfAccounts * perAccount);
for (var i = 1; i <= numberOfAccounts; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var loginName = GetLoginName(i);
var existing = await context.GetAccountByLoginNameAsync(loginName, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
continue;
}
var account = context.CreateNew<Account>();
account.LoginName = loginName;
account.PasswordHash = BCrypt.Net.BCrypt.HashPassword(Guid.NewGuid().ToString(), BotPasswordWorkFactor);
account.IsBot = true;
account.Vault = context.CreateNew<ItemStorage>();
for (byte slot = 0; slot < perAccount; slot++)
{
var characterClass = classQueue.Count > 0 ? classQueue.Dequeue() : creatableClasses.SelectRandom()!;
var level = minLevel + (int)((maxLevel - minLevel) * Math.Pow(Rand.NextInt(0, 1001) / 1000.0, LevelSkew));
var seededResets = maxSeededResets > 0 ? Rand.NextInt(0, maxSeededResets + 1) : 0;
var name = await this._nameGenerator.GenerateUniqueAsync(context, reservedNames, cancellationToken).ConfigureAwait(false);
this.CreateCharacter(context, account, name, characterClass, level, slot, experienceTable, seededResets, resetConfiguration);
}
// Save per account so a single failure does not roll back already generated accounts,
// and re-runs simply resume where they left off (idempotent).
if (await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false))
{
created++;
this._logger.LogInformation("Generated bot account '{LoginName}' with {Count} character(s).", loginName, perAccount);
}
}
return created;
}
/// <summary>
/// Deletes all bot accounts (and, by cascade, their characters and owned data).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of deleted bot accounts.</returns>
public async ValueTask<int> DeleteAllBotsAsync(CancellationToken cancellationToken = default)
{
using var context = this._gameContext.PersistenceContextProvider.CreateNewPlayerContext(this._gameContext.Configuration);
var deleted = 0;
const int pageSize = 100;
var skip = 0;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var page = (await context.GetAccountsOrderedByLoginNameAsync(skip, pageSize, cancellationToken).ConfigureAwait(false)).ToList();
if (page.Count == 0)
{
break;
}
var bots = page.Where(a => a.IsBot).ToList();
var removed = 0;
foreach (var bot in bots)
{
if (await context.DeleteAsync(bot).ConfigureAwait(false))
{
deleted++;
removed++;
}
else
{
// Not silent: a bot account which survives the reset is spawned again right after
// it, and the paging below would never look at it a second time.
this._logger.LogWarning("Bot account '{LoginName}' could not be deleted for the bot reset.", bot.LoginName);
}
}
// Commit this page's deletions before paging on, so the ordering used by the next query
// reflects the removals: what is left of this page now occupies [skip, skip + kept).
if (removed > 0)
{
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
skip += page.Count - removed;
}
return deleted;
}
private static byte[] CreateDefaultKeyConfiguration()
{
// Mirrors CreateCharacterAction: bind Q to the healing potion and W to the mana potion,
// leave E and R unbound. An all-zero blob would otherwise bind the apple (a heal) to all slots.
const byte healingPotion = 1;
const byte manaPotion = 4;
const byte unbound = 0xFF;
var keyConfiguration = new byte[30];
keyConfiguration[21] = healingPotion; // Q
keyConfiguration[22] = manaPotion; // W
keyConfiguration[23] = unbound; // E
keyConfiguration[25] = unbound; // R
return keyConfiguration;
}
/// <summary>
/// Builds a shuffled queue of character classes with even quotas across <paramref name="classes"/>,
/// so the generated population is balanced instead of relying on the variance of independent random
/// draws. The order is randomized so accounts do not get a predictable class pattern.
/// </summary>
private static Queue<CharacterClass> BuildBalancedClassQueue(IList<CharacterClass> classes, int total)
{
var pool = new List<CharacterClass>(total);
for (var n = 0; n < total; n++)
{
// Even quotas: class index cycles, so each class appears total/count times (+1 for the first remainder classes).
pool.Add(classes[n % classes.Count]);
}
// Fisher-Yates shuffle so the balanced pool is handed out in random order.
for (var n = pool.Count - 1; n > 0; n--)
{
var j = Rand.NextInt(0, n + 1);
(pool[n], pool[j]) = (pool[j], pool[n]);
}
return new Queue<CharacterClass>(pool);
}
/// <summary>
/// Spends the character's level-up points, so a high-level bot actually has high-level stats.
/// Without this a generated level-80 bot would fight with level-1 base stats (tiny health and
/// damage) and die instantly. The split follows the class build in <see cref="BotProgression"/>
/// for the server's meta profile (reset vs classic) - the same split the bot keeps using for
/// points it earns at runtime - and respects each stat's configured maximum (fun servers) as
/// well as the bot's personal vitality target on reset-meta servers.
/// </summary>
private static void DistributeStatPoints(Character character, CharacterClass characterClass, bool resetMeta)
{
var points = character.LevelUpPoints;
if (points <= 0)
{
return;
}
var weights = BotProgression.GetStatWeights(characterClass, character.Name, resetMeta);
var vitalityTarget = resetMeta ? BotProgression.GetVitalityTarget(character.Name) : (int?)null;
long CapacityOf(AttributeDefinition stat)
{
var attribute = character.Attributes.FirstOrDefault(a => a.Definition == stat);
if (attribute is null)
{
return 0;
}
var classBase = characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == stat);
var capacity = long.MaxValue;
if (classBase?.Attribute?.MaximumValue is { } maximumValue)
{
capacity = (long)maximumValue - (long)attribute.Value;
}
if (vitalityTarget is { } target && stat == Stats.BaseVitality)
{
var invested = (long)attribute.Value - (long)(classBase?.BaseValue ?? 0f);
capacity = Math.Min(capacity, target - invested);
}
return capacity;
}
foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights, CapacityOf))
{
var attribute = character.Attributes.FirstOrDefault(a => a.Definition == stat);
if (attribute is not null)
{
attribute.Value += amount;
character.LevelUpPoints -= amount;
}
}
}
/// <summary>
/// Calculates the level-up points a character with the given reset history would have available,
/// so a seeded bot invests the same total a player of that history would: the points granted by
/// the resets themselves (looped through <see cref="ResetProgressionCalculator"/>, so tiers,
/// multipliers and the replace/add mode of the server's configuration all apply) plus the points
/// earned by leveling. With <see cref="ResetConfiguration.ResetStats"/> the level points of the
/// finished cycles were invested and wiped again at each reset, so only the current cycle's count;
/// without it every cycle's investment survived and still counts.
/// </summary>
private static int CalculateLevelUpPoints(CharacterClass characterClass, int level, int seededResets, ResetConfiguration? resetConfiguration)
{
var pointsPerLevel = (int)characterClass.StatAttributes.First(a => a.Attribute == Stats.PointsPerLevelUp).BaseValue;
if (seededResets <= 0 || resetConfiguration is null)
{
return (level - 1) * pointsPerLevel;
}
var pointsPerResetOverride = (int)(characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == Stats.PointsPerReset)?.BaseValue ?? 0f);
var resetPoints = 0;
for (var reset = 0; reset < seededResets; reset++)
{
var progression = ResetProgressionCalculator.Calculate(reset, pointsPerResetOverride, resetConfiguration);
resetPoints = resetConfiguration.ReplacePointsPerReset
? progression.TotalPointsAfterReset
: resetPoints + progression.PointsForReset;
}
var currentCyclePoints = Math.Max(0, level - resetConfiguration.LevelAfterReset) * pointsPerLevel;
if (resetConfiguration.ResetStats)
{
return resetPoints + currentCyclePoints;
}
var firstCyclePoints = Math.Max(0, resetConfiguration.RequiredLevel - 1) * pointsPerLevel;
var laterCyclesPoints = (seededResets - 1) * Math.Max(0, resetConfiguration.RequiredLevel - resetConfiguration.LevelAfterReset) * pointsPerLevel;
return resetPoints + firstCyclePoints + laterCyclesPoints + currentCyclePoints;
}
private void CreateCharacter(IPlayerContext context, Account account, string name, CharacterClass characterClass, int level, byte slot, long[] experienceTable, int seededResets, ResetConfiguration? resetConfiguration)
{
// A character generated beyond the class evolution level was created as its second-generation
// class right away - like a player who completed the class quest long ago. Everything downstream
// (stat weights, skills, gear) keys off the evolved class. A character with a seeded reset
// history evolved in its first cycle at the latest - provided the reset's required level lies
// beyond the evolution level (the check below), which makes it pass the evolution on the way
// to its first reset regardless of its current in-cycle level.
var passedEvolutionInEarlierCycle = seededResets > 0 && resetConfiguration?.RequiredLevel >= BotProgression.ClassEvolutionLevel;
if ((level >= BotProgression.ClassEvolutionLevel || passedEvolutionInEarlierCycle)
&& BotProgression.GetEvolutionTarget(characterClass) is { } evolvedClass)
{
characterClass = evolvedClass;
}
var character = context.CreateNew<Character>();
character.CharacterClass = characterClass;
character.Name = name;
character.CharacterSlot = slot;
character.CreateDate = DateTime.UtcNow;
character.KeyConfiguration = CreateDefaultKeyConfiguration();
foreach (var attribute in characterClass.StatAttributes.Select(a => context.CreateNew<StatAttribute>(a.Attribute, a.BaseValue)))
{
character.Attributes.Add(attribute);
}
character.CurrentMap = characterClass.HomeMap;
var spawnGate = character.CurrentMap!.ExitGates.Where(g => g.IsSpawnGate).SelectRandom();
if (spawnGate is not null)
{
character.PositionX = (byte)Rand.NextInt(spawnGate.X1, spawnGate.X2);
character.PositionY = (byte)Rand.NextInt(spawnGate.Y1, spawnGate.Y2);
}
var levelAttribute = character.Attributes.First(a => a.Definition == Stats.Level);
levelAttribute.Value = level;
if (seededResets > 0
&& character.Attributes.FirstOrDefault(a => a.Definition == Stats.Resets) is { } resetsAttribute)
{
// Persisted exactly like a real player's resets (a per-character stat attribute), so the
// reset counter, the effective level and the reset limit all see the seeded history.
resetsAttribute.Value = seededResets;
}
character.Experience = experienceTable[Math.Min(level, experienceTable.Length - 1)];
character.LevelUpPoints = CalculateLevelUpPoints(characterClass, level, seededResets, resetConfiguration);
character.InventoryExtensions = BotInventoryExtensions;
DistributeStatPoints(character, characterClass, resetConfiguration is not null);
// Skills survive resets, so a seeded veteran knows everything the highest level of its past
// cycles unlocked - level-gated skills are checked against that level, not the current one.
var highestLevelReached = seededResets > 0 && resetConfiguration is not null
? Math.Max(level, resetConfiguration.RequiredLevel)
: level;
this.LearnClassSkills(context, character, characterClass, highestLevelReached);
character.Inventory = context.CreateNew<ItemStorage>();
character.Inventory.Money = StartMoney;
this.EquipStarterGear(context, character, resetConfiguration is not null);
account.Characters.Add(character);
}
/// <summary>
/// Teaches the character the class skills appropriate to its level and stats - attack skills as well
/// as the class's own buffs and heals (e.g. elf Heal/Greater Defense/Greater Damage). Only skills the
/// class is qualified for are ever learned, gated by the skills' real learn requirements from the game
/// configuration (total energy, leadership, character level, ...) evaluated against the stats the bot
/// was just given - exactly the requirements a human player has to meet for the same skill.
/// </summary>
private void LearnClassSkills(IPlayerContext context, Character character, CharacterClass characterClass, int level)
{
float? GetValue(AttributeDefinition attribute)
{
if (BotProgression.TotalToBaseStat(attribute) is not { } baseStat)
{
return null;
}
return baseStat == Stats.Level
? level
: character.Attributes.FirstOrDefault(a => a.Definition == baseStat)?.Value;
}
var learnedNumbers = new HashSet<short>(character.LearnedSkills.Select(s => s.Skill!.Number));
foreach (var skill in this._gameContext.Configuration.Skills)
{
if (!BotProgression.IsBotLearnableSkill(skill)
|| !skill.QualifiedCharacters.Contains(characterClass)
|| !BotProgression.MeetsRequirements(skill, GetValue)
|| !learnedNumbers.Add(skill.Number))
{
continue;
}
var entry = context.CreateNew<SkillEntry>();
entry.Skill = skill;
entry.Level = 0;
character.LearnedSkills.Add(entry);
}
}
/// <summary>
/// Equips the bot with a basic, class-appropriate weapon and armor set (mirrors the low-level test
/// account gear), so it is not naked and punching with its fists. The item level scales modestly
/// with the bot level for a bit more defense/damage without raising the equip requirements too high.
/// </summary>
private void EquipStarterGear(IPlayerContext context, Character character, bool resetMeta)
{
var inventory = character.Inventory!;
var characterClass = character.CharacterClass!;
// Data-driven so every class gets gear it is actually QUALIFIED to wear (a Dark Lord must never
// end up in a Pad/wizard set). We pick the most basic options (lowest DropLevel) the class can use:
// - a weapon from the weapon groups (0 sword, 1 axe, 2 mace, 3 spear, 4 bow, 5 staff),
// - the armor set whose chest piece (group 8) has the lowest DropLevel; its NUMBER identifies the set,
// and the equipment type is the GROUP (7 helm, 8 armor, 9 pants, 10 gloves, 11 boots).
// The weapon type follows the bot's BUILD (BotProgression.IsPreferredWeaponGroup - the same rule the
// later upgrades use), so an energy-specced Magic Gladiator starts with a staff instead of a blade.
// The Small Axe is qualified for almost every class, so without this filter casters and archers would
// all end up with one.
bool IsPreferredWeapon(ItemDefinition definition)
=> BotProgression.IsPreferredWeaponGroup(characterClass, character.Name, resetMeta, (byte)definition.Group);
// Ammunition shares the bow group (Bolt/Arrows have DropLevel 0), so without this filter every
// archer would get a bolt stack as its "weapon" and end up punching with its fists.
var weapon = this._gameContext.Configuration.Items
.Where(d => IsPreferredWeapon(d) && !d.IsAmmunition && d.QualifiedCharacters.Contains(characterClass))
.MinBy(d => d.DropLevel)
?? this._gameContext.Configuration.Items
.Where(d => d.Group <= StaffGroup && !d.IsAmmunition && d.QualifiedCharacters.Contains(characterClass))
.MinBy(d => d.DropLevel);
if (weapon is not null)
{
if (weapon.Group == BowGroup)
{
// Bows need ammunition; the arrows go into the left hand.
this.AddEquippedItem(context, inventory, characterClass, InventoryConstants.RightHandSlot, weapon);
this.AddAmmunition(context, inventory);
}
else
{
this.AddEquippedItem(context, inventory, characterClass, InventoryConstants.LeftHandSlot, weapon);
}
}
// Choose a thematically appropriate armor set the class can wear, tried in order (warriors -> Leather,
// wizards -> Pad, elves -> Vine, summoners -> Mistery, then fallbacks). Each piece is added only if the
// class is qualified for it, so e.g. the Magic Gladiator keeps the set but skips the helm it can't wear.
foreach (var set in ArmorSetCandidates)
{
if (this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == ArmorGroup && d.Number == set) is not { } chest
|| !chest.QualifiedCharacters.Contains(characterClass))
{
continue;
}
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.HelmSlot, 7, set);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.ArmorSlot, 8, set);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.PantsSlot, 9, set);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.GlovesSlot, 10, set);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.BootsSlot, 11, set);
break;
}
this.AddPotions(context, inventory);
}
private void AddPotions(IPlayerContext context, ItemStorage inventory)
{
// A stack of Large Healing Potions so the offline HealingHandler has something to drink, and a
// stack of Large Mana Potions so casters can keep casting instead of degrading to weak melee once
// their mana runs dry. The BotNavigator tops both up at runtime, so the bot never runs out.
// Durability holds the stack count.
this.AddPotionStack(context, inventory, 3, InventoryConstants.EquippableSlotsCount); // Large Healing Potion, first backpack slot
this.AddPotionStack(context, inventory, 6, (byte)(InventoryConstants.EquippableSlotsCount + 1)); // Large Mana Potion, second backpack slot
}
private void AddPotionStack(IPlayerContext context, ItemStorage inventory, byte potionNumber, byte slot)
{
var potion = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == 14 && d.Number == potionNumber);
if (potion is null)
{
return;
}
var item = context.CreateNew<Item>();
item.Definition = potion;
// Only a handful of charges to start with: fresh bots head to the merchant right away and buy
// their supplies with their starting Zen, kicking off the shopping economy from minute one
// (kept just above the emergency top-up threshold, so the economy path - not the fallback - runs).
item.Durability = Rand.NextInt(10, 16);
item.ItemSlot = slot;
inventory.Items.Add(item);
}
private void EquipArmorPiece(IPlayerContext context, ItemStorage inventory, CharacterClass characterClass, byte slot, int group, int number)
{
var definition = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == group && d.Number == number);
if (definition is null || !definition.QualifiedCharacters.Contains(characterClass))
{
return;
}
this.AddEquippedItem(context, inventory, characterClass, slot, definition);
}
private void AddEquippedItem(IPlayerContext context, ItemStorage inventory, CharacterClass characterClass, byte slot, ItemDefinition definition)
{
if (!definition.QualifiedCharacters.Contains(characterClass))
{
return;
}
var item = context.CreateNew<Item>();
item.Definition = definition;
item.Level = StarterItemLevel;
item.Durability = definition.Durability;
item.ItemSlot = slot;
inventory.Items.Add(item);
}
private void AddAmmunition(IPlayerContext context, ItemStorage inventory)
{
var arrows = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == 4 && d.Number == 15);
if (arrows is null)
{
return;
}
var item = context.CreateNew<Item>();
item.Definition = arrows;
item.Durability = 255;
item.ItemSlot = InventoryConstants.LeftHandSlot;
inventory.Items.Add(item);
}
}

View File

@@ -0,0 +1,228 @@
// <copyright file="BotJewelHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions;
using MUnique.OpenMU.GameLogic.PlayerActions.Items;
/// <summary>
/// Lets a bot invest its looted jewels into its own gear like a real player would: after a shopping
/// trip (a rare, safe moment in town), it takes a piece of equipment off, applies a Jewel of Bless,
/// Soul or Life through the regular <see cref="ItemConsumeAction"/> - the same validations, success
/// rates and failure penalties as for a human - and puts the piece back on.
/// The policy mirrors common player behavior: Bless (always succeeds) pushes the weakest equipped
/// piece towards +6, whether it has luck or not; Soul (50%, +25% with luck; a failure at +6 drops
/// the item to +5, from +7 on it resets it to +0) is only risked with a spare in stock, on plain
/// items only at +6 and up to +9 only on lucky ones; Life (50%, removes the option on failure) is
/// used sparingly on already upgraded gear. Only a couple of jewels are spent per trip, so the
/// upgrades trickle in over many visits like for a player instead of the whole hoard being burned
/// at once.
/// </summary>
internal static class BotJewelHandler
{
/// <summary>Upper bound of jewel consumptions per shopping trip - a player doesn't burn the whole hoard at once.</summary>
private const int MaxUsesPerTrip = 2;
/// <summary>The Jewel of Bless upgrades item levels 0..5 (see <c>BlessJewelConsumeHandlerPlugIn</c>).</summary>
private const byte BlessMaxTargetLevel = 5;
/// <summary>Souls are never spent below +6 - that range is Bless territory (safe and cheap).</summary>
private const byte SoulMinTargetLevel = 6;
/// <summary>
/// Without luck a Soul is only risked at +6, where a failure merely drops the item to +5 (a Bless
/// restores that): from +7 on, a failed Soul resets the item to +0
/// (<c>ResetToLevel0WhenFailMinLevel</c> in <c>SoulJewelConsumeHandlerPlugIn</c>), and at the base
/// 50% success rate that gamble wipes gear more often than not.
/// </summary>
private const byte SoulMaxTargetLevelPlain = 6;
/// <summary>
/// With luck (+25% success) the Soul may be risked up to +8, so lucky items can reach the jewel
/// ceiling of +9 (<c>MaximumLevel</c> in <c>SoulJewelConsumeHandlerPlugIn</c>).
/// </summary>
private const byte SoulMaxTargetLevelLucky = 8;
/// <summary>Only risk a Soul with at least this many in stock - one failure must not wipe out the reserve.</summary>
private const int MinSoulStock = 2;
/// <summary>Life is only worth risking on gear which already proved worth upgrading.</summary>
private const byte LifeMinTargetLevel = 6;
/// <summary>Only risk a Life with at least this many in stock.</summary>
private const int MinLifeStock = 2;
private static readonly ItemConsumeAction ConsumeAction = new();
private static readonly MoveItemAction MoveAction = new();
/// <summary>
/// Spends up to <see cref="MaxUsesPerTrip"/> looted jewels on the bot's own equipment. Call this
/// right after a finished merchant trade: the bot stands in the safezone, the NPC dialog is closed
/// (player state is back at <c>EnteredWorld</c>, which the consume handlers require), and the
/// navigator's shopping cooldown provides the rare, player-like cadence.
/// </summary>
/// <param name="player">The bot player.</param>
public static async ValueTask TryUpgradeGearAsync(OfflinePlayer player)
{
if (player.Inventory is null)
{
return;
}
var uses = 0;
var lifeUsed = false;
while (uses < MaxUsesPerTrip && PlanNextUse(player, lifeUsed) is { } plan)
{
if (!await ApplyJewelAsync(player, plan.Jewel, plan.Target).ConfigureAwait(false))
{
break;
}
uses++;
lifeUsed |= plan.IsLife;
}
if (uses > 0)
{
try
{
// Persist right away like after a bot reset - a rolled Soul/Life outcome shouldn't be
// replayable by losing it to a crash before the next periodic save.
await player.SaveProgressAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after using jewels; the periodic save will retry.", player.Name);
}
}
}
/// <summary>
/// Picks the next (jewel, equipped target item) pair according to the player-like policy, or
/// <c>null</c> when nothing sensible is left to do. Pure decision logic - exposed for unit tests.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="lifeUsed">Whether a Jewel of Life was already used this trip (at most one).</param>
internal static (Item Jewel, Item Target, bool IsLife)? PlanNextUse(Player player, bool lifeUsed)
{
if (player.Inventory is not { } inventory)
{
return null;
}
var backpack = inventory.Items.Where(i => i.ItemSlot > InventoryConstants.LastEquippableItemSlotIndex).ToList();
var equipped = inventory.Items
.Where(i => i.ItemSlot <= InventoryConstants.LastEquippableItemSlotIndex
&& i.Definition?.IsAmmunition == false)
.ToList();
var blessStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfBless)).ToList();
var soulStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfSoul)).ToList();
var lifeStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfLife)).ToList();
// 1. Bless - free progress: push the weakest equipped piece towards +6.
if (blessStock.Count > 0
&& equipped.Where(i => i.CanLevelBeUpgraded() && i.Level <= BlessMaxTargetLevel)
.OrderBy(i => i.Level)
.FirstOrDefault() is { } blessTarget)
{
return (blessStock[0], blessTarget, false);
}
// 2. Soul - risky: only with a spare in stock, and only where the possible loss is bearable -
// items without luck stop at +6 -> +7 (see SoulMaxTargetLevelPlain), lucky ones may go for +9.
if (soulStock.Count >= MinSoulStock
&& equipped.Where(i => i.CanLevelBeUpgraded()
&& i.Level >= SoulMinTargetLevel
&& i.Level <= (HasLuck(i) ? SoulMaxTargetLevelLucky : SoulMaxTargetLevelPlain))
.OrderByDescending(HasLuck)
.ThenBy(i => i.Level)
.FirstOrDefault() is { } soulTarget)
{
return (soulStock[0], soulTarget, false);
}
// 3. Life - sparingly: at most one per trip, only on gear that is already +6 or better. Whether
// the item can actually carry the option is the consume handler's call; a rejected consume
// keeps the jewel.
if (!lifeUsed
&& lifeStock.Count >= MinLifeStock
&& equipped.Where(i => i.IsWearable() && i.Level >= LifeMinTargetLevel)
.OrderByDescending(i => i.Level)
.FirstOrDefault() is { } lifeTarget)
{
return (lifeStock[0], lifeTarget, true);
}
return null;
}
/// <summary>
/// Applies one jewel to one equipped item the way a player does it: take the piece off into a free
/// backpack slot (the consume handlers refuse to modify equipped items), consume the jewel on it
/// through the regular action, and wear the piece again - whatever the outcome, even a Soul
/// failure's downgraded item goes back on.
/// </summary>
/// <returns><c>true</c>, if the jewel was actually consumed.</returns>
private static async ValueTask<bool> ApplyJewelAsync(OfflinePlayer player, Item jewel, Item target)
{
var inventory = player.Inventory!;
var equipSlot = target.ItemSlot;
// A free slot is not enough - the piece needs a hole of its SIZE (a 2x3 armor does not fit into
// the 1x1 gap a jewel left behind), which is exactly what CheckInvSpace answers.
if (inventory.CheckInvSpace(target) is not { } freeSlot
|| freeSlot <= InventoryConstants.LastEquippableItemSlotIndex)
{
return false;
}
await MoveAction.MoveItemAsync(player, equipSlot, Storages.Inventory, freeSlot, Storages.Inventory).ConfigureAwait(false);
if (inventory.GetItem(freeSlot) != target)
{
// The unequip was rejected - don't force it.
return false;
}
var jewelSlot = jewel.ItemSlot;
var levelBefore = target.Level;
try
{
await ConsumeAction.HandleConsumeRequestAsync(player, jewelSlot, freeSlot, FruitUsage.Undefined).ConfigureAwait(false);
}
finally
{
// Wear the piece again in any case; the move action re-checks the requirements itself.
await MoveAction.MoveItemAsync(player, freeSlot, Storages.Inventory, equipSlot, Storages.Inventory).ConfigureAwait(false);
}
var consumed = inventory.GetItem(jewelSlot) != jewel;
if (consumed)
{
player.Logger.LogInformation(
"Bot '{Name}' used '{Jewel}' on '{Item}': level {Before} -> {After}.",
player.Name,
jewel.Definition?.Name,
target,
levelBefore,
target.Level);
}
return consumed;
}
private static bool IsJewel(Item item, ItemIdentifier identifier)
{
return item.Definition is { } definition
&& identifier == new ItemIdentifier(definition.Number, definition.Group);
}
private static bool HasLuck(Item item)
{
return item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Luck);
}
}

View File

@@ -0,0 +1,310 @@
// <copyright file="BotManager.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Collections.Concurrent;
using System.Linq;
using MUnique.OpenMU.GameLogic.Offline;
/// <summary>
/// Manages the lifecycle of server-side bots.
/// </summary>
/// <remarks>
/// A bot reuses the connection-less <see cref="OfflinePlayer"/> together with its MU Helper AI,
/// but is spawned in a fully standalone way: the account is loaded fresh in the bot's own
/// persistence context (see <see cref="OfflinePlayer.InitializeAsync"/>), so there is no
/// cross-context attach of entities owned by another player - which is the root cause of the
/// known data-corruption issue of the <c>/offlevel</c> handover. Because each bot drives a
/// distinct character in its own context, several bots can animate different characters of the
/// same account at once (the shared account row is only attached, never modified).
/// </remarks>
public sealed class BotManager
{
private readonly ConcurrentDictionary<string, BotPlayer> _bots = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets a snapshot of the currently active bots.
/// </summary>
public IReadOnlyCollection<BotPlayer> Bots => this._bots.Values.ToList();
/// <summary>
/// Spawns a bot which drives a specific character of the given account.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="loginName">The login name of an existing account.</param>
/// <param name="characterSlot">The character slot to drive; <c>null</c> drives the first character by slot.</param>
/// <returns><c>true</c> if a bot was started; <c>false</c> if it could not be started or was already active.</returns>
public async ValueTask<bool> SpawnBotAsync(IGameContext gameContext, string loginName, byte? characterSlot = null)
{
if (string.IsNullOrWhiteSpace(loginName))
{
return false;
}
var bot = new BotPlayer(gameContext);
var added = false;
string? key = null;
try
{
// Load the account through the bot's OWN persistence context (no cross-context attach).
var account = await bot.PersistenceContext.GetAccountByLoginNameAsync(loginName).ConfigureAwait(false);
var character = characterSlot is { } slot
? account?.Characters.FirstOrDefault(c => c.CharacterSlot == slot)
: account?.Characters.OrderBy(c => c.CharacterSlot).FirstOrDefault();
if (account is null || character is null)
{
bot.Logger.LogWarning("Bot account '{LoginName}' (slot {Slot}) could not be loaded or has no character.", loginName, characterSlot);
await bot.DisposeAsync().ConfigureAwait(false);
return false;
}
key = GetKey(loginName, character.CharacterSlot);
if (!this._bots.TryAdd(key, bot))
{
// Already animating this character.
await bot.DisposeAsync().ConfigureAwait(false);
return false;
}
added = true;
// Provide AI settings so the bot actually hunts (a missing config means a single-tile range).
bot.MuHelperSettings = new BotMuHelperSettings();
if (!await bot.InitializeAsync(loginName, character.Name).ConfigureAwait(false))
{
await this.RemoveAndDisposeAsync(key, bot).ConfigureAwait(false);
return false;
}
BotSkillProgressionPlugIn.CatchUpPendingProgress(bot);
bot.Logger.LogInformation("Bot started for account '{LoginName}', character '{Character}'.", loginName, character.Name);
return true;
}
catch (Exception ex)
{
bot.Logger.LogError(ex, "Failed to spawn bot for account '{LoginName}' (slot {Slot}).", loginName, characterSlot);
if (added && key is not null)
{
await this.RemoveAndDisposeAsync(key, bot).ConfigureAwait(false);
}
else
{
await bot.DisposeAsync().ConfigureAwait(false);
}
return false;
}
}
/// <summary>
/// Stops and removes all currently active bots.
/// </summary>
/// <returns>The task.</returns>
public async ValueTask StopAllAsync()
{
foreach (var key in this._bots.Keys.ToList())
{
if (this._bots.TryRemove(key, out var bot))
{
await StopAndDisposeAsync(bot, key, "shutdown").ConfigureAwait(false);
}
}
}
/// <summary>
/// Determines whether the given character of the given account is currently animated by a bot.
/// </summary>
/// <param name="loginName">The account login name.</param>
/// <param name="slot">The character slot.</param>
public bool IsActive(string loginName, byte slot) => this._bots.ContainsKey(GetKey(loginName, slot));
/// <summary>
/// Stops one randomly chosen bot (used by the presence rotation, so the population ebbs and flows
/// like a real player base). The bot leaves its party cleanly first, then disconnects - which also
/// saves its progress, like a regular logout.
/// </summary>
/// <returns>The name of the stopped bot's character, or null if no bot was active.</returns>
public async ValueTask<string?> StopRandomBotAsync()
{
var key = this._bots.Keys.ToList().SelectRandom();
if (key is null || !this._bots.TryRemove(key, out var bot))
{
return null;
}
var name = bot.Name;
try
{
if (bot.Party is { } party)
{
await party.KickMySelfAsync(bot).ConfigureAwait(false);
}
}
catch (Exception ex)
{
// A failed party goodbye must not skip the stop below - the party cleans up a
// disconnected member itself.
bot.Logger.LogWarning(ex, "Bot '{Key}' couldn't leave its party for the presence rotation.", key);
}
await StopAndDisposeAsync(bot, key, "presence rotation").ConfigureAwait(false);
return name;
}
/// <summary>
/// Stops the given bot like a regular logout and immediately brings the same character back
/// online - the ghost equivalent of a player relogging. Used after the master evolution: the
/// master class's base attributes (master experience rate, master points per level) and the
/// master level stat are only mounted when the character enters the world, so the class change
/// must be followed by a fresh world entry before master experience can flow.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="bot">The bot to restart.</param>
/// <returns><c>true</c> if the bot came back online.</returns>
public async ValueTask<bool> RestartBotAsync(IGameContext gameContext, BotPlayer bot)
{
// Captured before the stop - the disposed bot loses its account and character.
var loginName = bot.Account?.LoginName;
var characterSlot = bot.SelectedCharacter?.CharacterSlot;
if (loginName is null
|| characterSlot is not { } slot
|| !this._bots.TryRemove(GetKey(loginName, slot), out var removed))
{
return false;
}
if (removed.Party is { } party)
{
try
{
await party.KickMySelfAsync(removed).ConfigureAwait(false);
}
catch (Exception ex)
{
// A failed party goodbye must not skip the stop below - the party cleans up a
// disconnected member itself.
removed.Logger.LogWarning(ex, "Bot '{Login}/{Slot}' couldn't leave its party for the restart.", loginName, slot);
}
}
try
{
await removed.StopAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
// The old instance may still be (partially) alive - put it back under management and
// don't spawn a second player driving the same character (two persistence contexts
// saving one character would be last-write-wins data loss). Retried on a later pass.
removed.Logger.LogError(ex, "Error while stopping bot '{Login}/{Slot}' for a restart; keeping the old instance.", loginName, slot);
this._bots.TryAdd(GetKey(loginName, slot), removed);
return false;
}
// The stopped instance is done for good - release its persistence context and the tracked
// account graph before the fresh one loads them again.
await removed.DisposeAsync().ConfigureAwait(false);
return await this.SpawnBotAsync(gameContext, loginName, slot).ConfigureAwait(false);
}
/// <summary>
/// Groups a share of the active bots into small hunting parties of level-wise similar characters,
/// like real players do: the party members follow their leader (see the follow logic in
/// <see cref="BotNavigator"/>), the elf heals the group, buffs are shared and the party experience
/// bonus applies. The rest of the bots keep hunting solo, so the population stays varied.
/// </summary>
/// <param name="gameContext">The game context (provides the party manager).</param>
public async ValueTask FormPartiesAsync(IGameContext gameContext)
{
const int minPartySize = 2;
const int maxPartySize = 5;
const int maxLevelGap = 12;
const int partiedSharePercent = 60;
// Matched by the reset-aware effective level (see BotResetHandler.GetEffectiveLevel), so on a
// reset server a freshly reset veteran groups with its peers instead of with real newbies.
var candidates = this._bots.Values
.Where(b => b.Party is null && b.Attributes is not null)
.OrderBy(BotResetHandler.GetEffectiveLevel)
.ToList();
var index = 0;
while (index < candidates.Count - 1)
{
if (Rand.NextInt(0, 100) >= partiedSharePercent)
{
index++; // this bot stays solo
continue;
}
var leader = candidates[index];
var leaderLevel = BotResetHandler.GetEffectiveLevel(leader);
var targetSize = Rand.NextInt(minPartySize, maxPartySize + 1);
var members = new List<BotPlayer> { leader };
var next = index + 1;
while (next < candidates.Count
&& members.Count < targetSize
&& BotResetHandler.GetEffectiveLevel(candidates[next]) - leaderLevel <= maxLevelGap)
{
members.Add(candidates[next]);
next++;
}
if (members.Count >= minPartySize)
{
var party = gameContext.PartyManager.CreateParty();
foreach (var member in members)
{
if (!await party.AddAsync(member).ConfigureAwait(false))
{
break;
}
}
leader.Logger.LogInformation(
"Formed bot party of {Count} around '{Leader}' (level {Level}).",
members.Count,
leader.Name,
leaderLevel);
}
index = next;
}
}
private static string GetKey(string loginName, byte slot) => $"{loginName}/{slot}";
/// <summary>
/// Stops the bot like a regular logout (which saves its progress) and releases its resources.
/// A stopped-but-not-disposed player keeps its persistence context - and with it the whole tracked
/// account graph - alive; with the presence rotation stopping bots around the clock, that adds up
/// to a leak. Mirrors the teardown of the <see cref="Offline.OfflinePlayerManager"/>; the removal
/// from the game context happens through the disconnect event.
/// </summary>
private static async ValueTask StopAndDisposeAsync(BotPlayer bot, string key, string reason)
{
try
{
await bot.StopAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
bot.Logger.LogError(ex, "Error while stopping bot '{Key}' ({Reason}).", key, reason);
}
finally
{
await bot.DisposeAsync().ConfigureAwait(false);
}
}
private async ValueTask RemoveAndDisposeAsync(string key, BotPlayer bot)
{
this._bots.TryRemove(key, out _);
await bot.DisposeAsync().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,351 @@
// <copyright file="BotMasterHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions.Character;
/// <summary>
/// Handles the third-generation ("master") stage of a bot's career: the evolution into the master
/// class at the game's maximum level - the same class assignment the level-400 master quests perform
/// for a human player - and the investment of the master points earned per master level, through the
/// regular <see cref="AddMasterPointAction"/> with all its validations.
/// </summary>
/// <remarks>
/// On servers with the reset feature the evolution follows one iron rule: a bot only becomes a master
/// when no reset can ever follow, i.e. the configured reset limit is exhausted. While resets remain -
/// or when no limit is configured at all, so resetting forever is the endgame - the bot keeps resetting
/// and never masters, like the players of such servers. Without the reset feature it evolves as soon as
/// it reaches the maximum level.
/// The master class's base attributes (master experience rate, master points per level) and the master
/// level stat are only mounted into the attribute system when the character enters the world, so the
/// caller must restart the bot after the evolution (see <c>BotManager.RestartBotAsync</c>) - the ghost
/// equivalent of a player relogging; master experience only flows after that fresh world entry.
/// </remarks>
internal static class BotMasterHandler
{
/// <summary>
/// A master skill unlocks the next rank of its root (and satisfies "required skill" links) at this
/// level, see <see cref="AddMasterPointAction"/>.
/// </summary>
private const int RankUnlockLevel = 10;
/// <summary>The item groups of the weapons a master bonus can be tied to (see <see cref="WeaponGroupOfBonus"/>).</summary>
private const byte SwordGroup = 0;
/// <summary>The item group of the maces - the scepters live here as well.</summary>
private const byte MaceGroup = 2;
/// <summary>The item group of the spears.</summary>
private const byte SpearGroup = 3;
/// <summary>The item group of the bows and crossbows.</summary>
private const byte BowGroup = 4;
/// <summary>The item group of the staffs - the sticks and books live here as well.</summary>
private const byte StaffGroup = 5;
private static readonly AddMasterPointAction AddPointAction = new();
/// <summary>
/// Determines whether the bot is due for its master evolution: it has a master class to evolve
/// into, stands at the game's maximum level, and - on reset servers - exhausted the reset limit
/// (see the remarks of <see cref="BotMasterHandler"/> for the rationale).
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns>True, if the bot should evolve into its master class now.</returns>
public static bool IsMasterEvolutionDue(Player player)
{
if (player.SelectedCharacter is not { CharacterClass: { } currentClass }
|| player.Attributes is not { } attributes
|| BotProgression.GetMasterEvolutionTarget(currentClass) is null)
{
return false;
}
if ((int)attributes[Stats.Level] < player.GameContext.Configuration.MaximumLevel)
{
return false;
}
if (BotResetHandler.GetResetConfiguration(player.GameContext) is { } resetConfiguration
&& (resetConfiguration.ResetLimit is not > 0
|| (int)attributes[Stats.Resets] < resetConfiguration.ResetLimit))
{
return false;
}
return true;
}
/// <summary>
/// Evolves the bot into its master class when due. The caller must restart the bot afterwards
/// (see the remarks of <see cref="BotMasterHandler"/>).
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns>True, if the evolution was performed.</returns>
public static async ValueTask<bool> TryEvolveAsync(OfflinePlayer player)
{
if (!IsMasterEvolutionDue(player) || player.SelectedCharacter is not { } character)
{
return false;
}
var masterClass = BotProgression.GetMasterEvolutionTarget(character.CharacterClass!)!;
character.CharacterClass = masterClass;
player.Logger.LogInformation(
"Bot '{Name}' evolved into master class {Class} at level {Level}.",
player.Name,
masterClass.Name,
player.Level);
try
{
// Persist right away like a performed reset - the following restart reloads the character
// from the database, so the class change must be down there before it.
await player.SaveProgressAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after its master evolution; the logout save will retry.", player.Name);
}
return true;
}
/// <summary>
/// Determines whether the bot is a master with points to invest - the cheap per-tick guard for
/// queueing <see cref="TrySpendMasterPointsAsync"/>.
/// </summary>
/// <param name="player">The bot player.</param>
/// <returns>True, if there are master points to spend.</returns>
public static bool HasMasterPointsToSpend(Player player)
=> player.SelectedCharacter is { CharacterClass.IsMasterClass: true, MasterLevelUpPoints: > 0 };
/// <summary>
/// Invests the bot's available master points (earned one per master level) into its master skill
/// tree through the regular <see cref="AddMasterPointAction"/>. Learning a skill mutates the
/// skill list, so this must run inside the bot's AI tick - queue it via
/// <see cref="OfflinePlayer.PendingBotActions"/> (see the call site in <c>BotNavigator</c>);
/// with no points available it is a cheap no-op.
/// </summary>
/// <param name="player">The bot player.</param>
public static async ValueTask TrySpendMasterPointsAsync(OfflinePlayer player)
{
if (player.SelectedCharacter is not { CharacterClass.IsMasterClass: true } character
|| character.MasterLevelUpPoints < 1)
{
return;
}
while (character.MasterLevelUpPoints > 0 && PickNextMasterSkill(player) is { } skill)
{
var pointsBefore = character.MasterLevelUpPoints;
await AddPointAction.AddMasterPointAsync(player, (ushort)skill.Number).ConfigureAwait(false);
if (character.MasterLevelUpPoints >= pointsBefore)
{
// The action refused - its own checks are authoritative, don't loop on the same pick.
break;
}
player.Logger.LogInformation(
"Bot '{Name}' invested {Points} master point(s) into '{Skill}'.",
player.Name,
pointsBefore - character.MasterLevelUpPoints,
skill.Name);
}
}
/// <summary>
/// Picks the master skill the bot invests its next point into, or <c>null</c> when nothing is
/// eligible. The policy fills the tree like a player: first push a started skill to the rank-unlock
/// level of 10, then learn a new eligible skill - preferring "useful" ones, i.e. passives boosting a
/// stat or strengtheners of a skill the bot actually has - and finally pump the learned skills
/// towards their maximum. Deterministic, so a bot builds the same tree across sessions.
/// Pure decision logic - exposed for unit tests.
/// </summary>
/// <param name="player">The bot player.</param>
internal static Skill? PickNextMasterSkill(Player player)
{
if (player.SelectedCharacter is not { CharacterClass: { } characterClass } character
|| player.SkillList is not { } skillList)
{
return null;
}
var learned = character.LearnedSkills
.Where(l => l.Skill?.MasterDefinition?.Root is not null)
.ToList();
if (learned
.Where(l => l.Level < RankUnlockLevel && l.Level < l.Skill!.MasterDefinition!.MaximumLevel)
.OrderBy(l => l.Skill!.MasterDefinition!.Rank)
.ThenBy(l => l.Skill!.Number)
.FirstOrDefault() is { } gate)
{
return gate.Skill;
}
if (player.GameContext.Configuration.Skills
.Where(s => s.MasterDefinition?.Root is not null
&& s.QualifiedCharacters.Contains(characterClass)
&& character.LearnedSkills.All(l => l.Skill != s)
&& CanLearn(player, s, character.MasterLevelUpPoints))
.OrderBy(s => IsUsefulPick(player, s, skillList) ? 0 : 1)
.ThenBy(s => s.MasterDefinition!.Rank)
.ThenBy(s => s.Number)
.FirstOrDefault() is { } newSkill)
{
return newSkill;
}
return learned
.Where(l => l.Level < l.Skill!.MasterDefinition!.MaximumLevel)
.OrderBy(l => IsUsefulPick(player, l.Skill!, skillList) ? 0 : 1)
.ThenBy(l => l.Skill!.MasterDefinition!.Rank)
.ThenBy(l => l.Skill!.Number)
.FirstOrDefault()?.Skill;
}
/// <summary>
/// Mirrors the private requisition checks of <see cref="AddMasterPointAction"/> (minimum points,
/// previous rank of the same root at 10+, required skills), so the picker only proposes skills the
/// action will accept. A mismatch is harmless: the action refuses and the spend loop stops.
/// </summary>
private static bool CanLearn(Player player, Skill skill, int availablePoints)
{
var definition = skill.MasterDefinition!;
if (availablePoints < definition.MinimumLevel)
{
return false;
}
if (definition.Rank > 1
&& !player.SelectedCharacter!.LearnedSkills.Any(l =>
l.Skill?.MasterDefinition?.Root is { } root
&& root.Id == definition.Root?.Id
&& l.Skill.MasterDefinition.Rank == definition.Rank - 1
&& l.Level >= RankUnlockLevel))
{
return false;
}
if (definition.RequiredMasterSkills?.Any() == true
&& !definition.RequiredMasterSkills.All(s =>
player.SelectedCharacter!.LearnedSkills.Any(l => l.Skill == s && l.Level >= RankUnlockLevel)
|| (s.MasterDefinition is null && player.SkillList?.ContainsSkill((ushort)s.Number) == true)))
{
return false;
}
return true;
}
/// <summary>
/// A pick is "useful" when it demonstrably does something for this bot: a passive boosting a stat,
/// or a strengthener/mastery of a skill the bot actually has in its list. A passive tied to a WEAPON
/// type the bot does not fight with is not (a bow strengthener does nothing for a bot swinging a
/// sword), and neither is one which only applies against other PLAYERS: a bot spends its life
/// hunting monsters, and it may not even attack a player unless attacked first (see
/// <see cref="BotPvpRules"/>). Both get filled last, after everything which actually helps.
/// </summary>
private static bool IsUsefulPick(Player player, Skill skill, ISkillList skillList)
{
var definition = skill.MasterDefinition!;
if (definition.TargetAttribute is { } target)
{
return !IsPvpOnlyBonus(target)
&& (WeaponGroupOfBonus(target) is not { } weaponGroup || CarriesWeaponOfGroup(player, weaponGroup));
}
return definition.ReplacedSkill is { } replaced && skillList.ContainsSkill((ushort)replaced.Number);
}
/// <summary>
/// Whether the master bonus only ever applies in a fight against another player, which is not what a
/// bot's points are for.
/// </summary>
/// <param name="attribute">The bonus attribute.</param>
private static bool IsPvpOnlyBonus(AttributeDefinition attribute)
{
return attribute == Stats.AttackRatePvp || attribute == Stats.DefenseRatePvp;
}
/// <summary>
/// The item group of the weapon a master bonus attribute belongs to, or <c>null</c> when the bonus
/// helps regardless of the weapon (health, defense, an attack rate, ...). The item groups come from
/// the item data: scepters live in the mace group, the Rage Fighter's gloves in the sword group, and
/// sticks and books next to the staffs.
/// </summary>
private static byte? WeaponGroupOfBonus(AttributeDefinition attribute)
{
if (attribute == Stats.OneHandedSwordBonusDamage
|| attribute == Stats.TwoHandedSwordStrBonusDamage
|| attribute == Stats.TwoHandedSwordMasteryBonusDamage
|| attribute == Stats.GloveWeaponBonusDamage)
{
return SwordGroup;
}
if (attribute == Stats.MaceBonusDamage
|| attribute == Stats.MaceMasteryStunChance
|| attribute == Stats.ScepterStrBonusDamage
|| attribute == Stats.ScepterMasteryBonusDamage
|| attribute == Stats.ScepterPetBonusDamage
|| attribute == Stats.BonusDamageWithScepterCmdDiv)
{
return MaceGroup;
}
if (attribute == Stats.SpearBonusDamage
|| attribute == Stats.SpearMasteryDoubleDamageChance)
{
return SpearGroup;
}
if (attribute == Stats.BowStrBonusDamage
|| attribute == Stats.CrossBowStrBonusDamage
|| attribute == Stats.CrossBowMasteryBonusDamage)
{
return BowGroup;
}
if (attribute == Stats.OneHandedStaffBonusBaseDamage
|| attribute == Stats.TwoHandedStaffBonusBaseDamage
|| attribute == Stats.TwoHandedStaffMasteryBonusDamage
|| attribute == Stats.StickBonusBaseDamage
|| attribute == Stats.StickMasteryBonusDamage
|| attribute == Stats.BookBonusBaseDamage)
{
return StaffGroup;
}
return null;
}
/// <summary>
/// Whether the bot fights with a weapon of this item group - what it carries right now, and what its
/// build makes it pick up in the future (see <see cref="BotProgression.IsPreferredWeaponGroup"/>), so
/// a bot which is momentarily unarmed does not start collecting bonuses for the wrong weapon.
/// </summary>
private static bool CarriesWeaponOfGroup(Player player, byte weaponGroup)
{
if (player.Inventory?.GetItem(InventoryConstants.LeftHandSlot)?.Definition is { } weapon
&& weapon.Group == weaponGroup)
{
return true;
}
return player.SelectedCharacter is { CharacterClass: { } characterClass } character
&& BotProgression.IsPreferredWeaponGroup(
characterClass,
character.Name,
BotResetHandler.GetResetConfiguration(player.GameContext) is not null,
weaponGroup);
}
}

View File

@@ -0,0 +1,177 @@
// <copyright file="BotMiniGameHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.MiniGames;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames;
/// <summary>
/// Lets server-side bots take part in the mini game events (Blood Castle, Devil Square, Chaos
/// Castle) - but only ever in the wake of a human: when a real player who leads a party with bots
/// enters an event with their own ticket, the party's bots follow them in. Bots never enter on
/// their own, and they don't need tickets - the leader's entry is what legitimizes the visit.
/// Each bot is checked against the same entry rules a player faces (the event level bracket,
/// the master class requirement, the player killer restriction); a bot which does not qualify
/// says goodbye and leaves the party to go back to its own hunting life, like a player who cannot
/// join the run. Inside, the bot's routine switches to the event mode of the
/// <see cref="BotNavigator"/>; death and the event's end need no special handling, because the
/// engine respawns a dead bot at the map's safezone (exactly like a player, which removes it from
/// the event) and the event itself warps the remaining participants out when it ends.
/// </summary>
internal static class BotMiniGameHandler
{
/// <summary>
/// Takes the snapshot of the bots which would follow the given player into a mini game.
/// Must be called BEFORE the player actually enters: entering an event which disallows
/// parties (Chaos Castle) kicks the entering player out of its party, and with it the
/// knowledge of who was going to follow.
/// </summary>
/// <param name="player">The player about to enter a mini game.</param>
/// <returns>The party bots to bring along; empty when the player is a bot itself, has no party or is not its master.</returns>
internal static IReadOnlyList<OfflinePlayer> SnapshotPartyBots(Player player)
{
if (player is OfflinePlayer
|| player.Party is not { } party
|| !ReferenceEquals(party.PartyMaster, player))
{
return [];
}
return party.PartyList
.OfType<OfflinePlayer>()
.Where(bot => bot.Account?.IsBot == true)
.ToList();
}
/// <summary>
/// Brings the party bots of a player who just successfully entered a mini game along into it.
/// Each bot's entry is queued into its own MuHelper tick (see <see cref="OfflinePlayer.PendingBotActions"/>),
/// because warping and effect-clearing mutate the bot's state.
/// </summary>
/// <param name="leader">The party leader who entered the mini game.</param>
/// <param name="bots">The snapshot taken by <see cref="SnapshotPartyBots"/> before the entry.</param>
/// <param name="definition">The definition of the entered mini game.</param>
/// <param name="miniGame">The mini game instance the leader entered.</param>
internal static void BringPartyBotsAlong(Player leader, IReadOnlyList<OfflinePlayer> bots, MiniGameDefinition definition, MiniGameContext miniGame)
{
foreach (var bot in bots)
{
bot.PendingBotActions.Enqueue(() => TryEnterAsync(bot, leader, definition, miniGame));
}
}
/// <summary>
/// Determines whether the bot passes the same entry restrictions <c>EnterMiniGameAction</c>
/// checks for a player: the event's character level bracket, the master class requirement and
/// the player killer restriction. Pure decision logic - exposed for unit tests.
/// </summary>
/// <param name="bot">The bot which wants to follow its leader in.</param>
/// <param name="definition">The mini game definition.</param>
/// <param name="reason">The human-readable reason when the bot does not qualify.</param>
internal static bool IsEligible(Player bot, MiniGameDefinition definition, out string reason)
{
var level = (int)(bot.Attributes?[Stats.Level] ?? 0);
// The special characters (Magic Gladiator, Dark Lord, Rage Fighter, Summoner) enter the events in
// their own level bracket - the same distinction EnterMiniGameAction makes for a player. Judging
// them by the regular bracket kicked a qualified Magic Gladiator out of its leader's party as
// "below the minimum" (and would have let it in past its own maximum).
var isSpecialCharacter = bot.SelectedCharacter?.IsSpecialCharacter() == true;
var minimumLevel = isSpecialCharacter ? definition.MinimumSpecialCharacterLevel : definition.MinimumCharacterLevel;
var maximumLevel = isSpecialCharacter ? definition.MaximumSpecialCharacterLevel : definition.MaximumCharacterLevel;
if (level < minimumLevel)
{
reason = $"level {level} is below the minimum of {minimumLevel}";
return false;
}
if (level > maximumLevel)
{
reason = $"level {level} is above the maximum of {maximumLevel}";
return false;
}
if (definition.RequiresMasterClass && bot.SelectedCharacter?.CharacterClass?.IsMasterClass is not true)
{
reason = "it has not evolved into a master class yet";
return false;
}
if (!definition.ArePlayerKillersAllowedToEnter && bot.SelectedCharacter?.State >= HeroState.PlayerKiller1stStage)
{
reason = "player killers cannot enter";
return false;
}
reason = string.Empty;
return true;
}
private static async ValueTask TryEnterAsync(OfflinePlayer bot, Player leader, MiniGameDefinition definition, MiniGameContext miniGame)
{
if (bot.PlayerState.CurrentState != PlayerState.EnteredWorld
|| !bot.IsAlive
|| bot.CurrentMiniGame is not null)
{
return;
}
if (!IsEligible(bot, definition, out var reason))
{
// Like a player who cannot join the run: the bot says goodbye and goes back to its
// own hunting life instead of waiting at the gate.
bot.Logger.LogInformation(
"Bot '{Name}' cannot follow '{Leader}' into {Event} ({Reason}) and leaves the party.",
bot.Name,
leader.Name,
definition.Name,
reason);
if (bot.Party is { } party)
{
await party.KickMySelfAsync(bot).ConfigureAwait(false);
}
return;
}
if (definition.Entrance is not { } entrance)
{
return;
}
var enterResult = await miniGame.TryEnterAsync(bot).ConfigureAwait(false);
if (enterResult != EnterResult.Success)
{
// Full or already closed - not the bot's fault; it stays in the party and waits for
// the leader outside, hunting normally.
bot.Logger.LogInformation(
"Bot '{Name}' could not follow '{Leader}' into {Event}: {Result}.",
bot.Name,
leader.Name,
definition.Name,
enterResult);
return;
}
// Mirror of the player entry flow in EnterMiniGameAction, without ticket and entrance fee.
if (!definition.AllowParty && bot.Party is { } noPartyEventParty)
{
await noPartyEventParty.KickMySelfAsync(bot).ConfigureAwait(false);
}
await bot.MagicEffectList.ClearEffectsAfterDeathAsync().ConfigureAwait(false);
await bot.RemoveSummonAsync().ConfigureAwait(false);
await bot.WarpToAsync(entrance).ConfigureAwait(false);
bot.Logger.LogInformation(
"Bot '{Name}' follows '{Leader}' into {Event}.",
bot.Name,
leader.Name,
definition.Name);
}
}

View File

@@ -0,0 +1,212 @@
// <copyright file="BotMuHelperSettings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.MuHelper;
/// <summary>
/// Default MU Helper settings used to drive a bot's combat AI.
/// A bot never sends a client-side MU Helper configuration, so without this the player would
/// fall back to a hunting range of a single tile (see <see cref="Offline.CombatHandler"/>).
/// These defaults make the bot hunt nearby monsters, pick up the valuable drops and use
/// potions, while staying close to its spawn origin.
/// </summary>
internal sealed class BotMuHelperSettings : IMuHelperSettings
{
/// <inheritdoc />
public int BasicSkillId => 0;
/// <inheritdoc />
public int ActivationSkill1Id => 0;
/// <inheritdoc />
public int ActivationSkill2Id => 0;
/// <inheritdoc />
public int DelayMinSkill1 => 0;
/// <inheritdoc />
public int DelayMinSkill2 => 0;
/// <inheritdoc />
public bool Skill1UseTimer => false;
/// <inheritdoc />
public bool Skill1UseCondition => false;
/// <inheritdoc />
public bool Skill1ConditionAttacking => false;
/// <inheritdoc />
public int Skill1SubCondition => 0;
/// <inheritdoc />
public bool Skill2UseTimer => false;
/// <inheritdoc />
public bool Skill2UseCondition => false;
/// <inheritdoc />
public bool Skill2ConditionAttacking => false;
/// <inheritdoc />
public int Skill2SubCondition => 0;
/// <inheritdoc />
public bool UseCombo => false;
/// <inheritdoc />
public int HuntingRange => 6;
/// <inheritdoc />
public int MaxSecondsAway => 30;
/// <inheritdoc />
public bool LongRangeCounterAttack => false;
/// <inheritdoc />
/// <remarks>
/// Disabled for bots: the <see cref="BotNavigator"/> is the sole driver of travel between hunting
/// grounds, so the offline movement handler must not try to walk the bot back to its origin in parallel.
/// </remarks>
public bool ReturnToOriginalPosition => false;
/// <inheritdoc />
public int BuffSkill0Id => 0;
/// <inheritdoc />
public int BuffSkill1Id => 0;
/// <inheritdoc />
public int BuffSkill2Id => 0;
/// <inheritdoc />
public bool BuffOnDuration => false;
/// <inheritdoc />
public bool BuffDurationForParty => false;
/// <inheritdoc />
public int BuffCastIntervalSeconds => 0;
// With the class heal skill learned (e.g. elf Heal), the HealingHandler casts it below the threshold
// before falling back to potions - the same order a real player follows.
/// <inheritdoc />
public bool AutoHeal => true;
/// <inheritdoc />
public int HealThresholdPercent => 60;
/// <inheritdoc />
public bool UseDrainLife => false;
/// <inheritdoc />
public bool UseHealPotion => true;
/// <inheritdoc />
public int PotionThresholdPercent => 60;
/// <inheritdoc />
// Bots hunt in small parties (see BotManager.FormParties): the elf heals the group, buffs are
// shared, and the party experience bonus applies - like a real group of players.
public bool SupportParty => true;
/// <inheritdoc />
public bool AutoHealParty => true;
/// <inheritdoc />
public int HealPartyThresholdPercent => 60;
/// <inheritdoc />
public bool UseDarkRaven => false;
/// <inheritdoc />
public int DarkRavenMode => 0;
/// <inheritdoc />
public int ObtainRange => 6;
/// <inheritdoc />
public bool PickAllItems => false;
/// <inheritdoc />
// Must be true: the pickup handler bails out early unless PickAllItems or PickSelectItems is set,
// so with this off the selective PickZen/PickJewel/PickAncient flags below never take effect.
public bool PickSelectItems => true;
/// <inheritdoc />
public bool PickJewel => true;
/// <inheritdoc />
public bool PickZen => true;
/// <inheritdoc />
public bool PickAncient => true;
/// <inheritdoc />
public bool PickExcellent => true;
/// <inheritdoc />
public bool PickExtraItems => false;
/// <inheritdoc />
public IReadOnlyList<string> ExtraItemNames => Array.Empty<string>();
/// <inheritdoc />
/// <remarks>
/// Disabled on purpose: offline auto-repair has no NPC discount and drains Zen at an
/// increased rate. Bots should not burn their balance on repairs during the proof of concept.
/// </remarks>
public bool RepairItem => false;
/// <inheritdoc />
/// <remarks>Bots fight back when a player attacks them (see <see cref="BotSelfDefensePlugIn"/>).</remarks>
public bool UseSelfDefense => true;
/// <inheritdoc />
public bool AutoAcceptFriend => false;
/// <inheritdoc />
public bool AutoAcceptGuild => false;
/// <inheritdoc />
/// <remarks>
/// Bots accept party invitations from any player, like a friendly stranger would - within the
/// safeguards applied by <see cref="BotPartyHandler"/> (level gap, not while busy, limited time).
/// </remarks>
public bool AutoAcceptAnyone => true;
/// <inheritdoc />
public bool FallbackBasicAttack => true;
/// <inheritdoc />
/// <remarks>
/// Enabled for bots: they have no client-side skill configuration, so the combat AI auto-selects the
/// strongest learned attack skill the character can currently afford. Combined with the level-gated
/// skills granted at generation (see <see cref="BotGenerator"/>), this makes bots cast class- and
/// level-appropriate magic/skills instead of only swinging their weapon.
/// </remarks>
public bool AutoSelectBestSkill => true;
/// <inheritdoc />
/// <remarks>Bots keep their class's learned buffs up automatically (e.g. elf Greater Defense/Greater Damage).</remarks>
public bool AutoSelectBuffs => true;
/// <inheritdoc />
/// <remarks>Casters drink mana potions, so they keep casting instead of degrading to weak melee.</remarks>
public bool UseManaPotion => true;
/// <inheritdoc />
/// <remarks>
/// Bots only engage monsters they can handle (the navigator's safe-monster cap). Without this, a bot
/// travelling through hostile territory picks fights with monsters far above its level and dies.
/// </remarks>
public bool OnlyHuntSafeMonsters => true;
/// <inheritdoc />
/// <remarks>Bots evaluate dropped gear and pick up upgrades for their own class (see <see cref="BotEquipmentHandler"/>).</remarks>
public bool PickUpgradeItems => true;
}

View File

@@ -0,0 +1,92 @@
// <copyright file="BotNameGenerator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Globalization;
using System.Threading;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Generates pronounceable, realistic-looking character names for bots.
/// </summary>
/// <remarks>
/// Names are built procedurally from syllables so the generator scales to thousands of unique
/// names while still looking like names a real player might pick. Every generated name satisfies
/// the default character name rules (3-10 alphanumeric characters), so bots blend in with players;
/// the bot nature is tracked by <see cref="DataModel.Entities.Account.IsBot"/>, never by the name.
/// </remarks>
internal sealed class BotNameGenerator
{
private static readonly string[] Starts =
{
"Dra", "Kar", "Mil", "Tho", "Zan", "Bel", "Gor", "Vyn", "Ael", "Mor",
"Run", "Syl", "Tor", "Kra", "Fen", "Lyr", "Nyx", "Ori", "Var", "Eld",
"Bro", "Cyn", "Dar", "Hal", "Ith", "Jor", "Kae", "Lor", "Mag", "Nor",
"Pyr", "Rha", "Ser", "Ulr", "Wyn", "Xan", "Yor", "Zel", "Ari", "Cae",
};
private static readonly string[] Middles =
{
string.Empty, string.Empty, string.Empty,
"a", "e", "i", "o", "ia", "ae", "an", "or", "el", "yn", "ar",
};
private static readonly string[] Ends =
{
"dor", "lin", "rik", "gar", "wyn", "ric", "mir", "dan", "eth", "ron",
"ana", "ella", "ix", "ael", "oth", "ara", "une", "is", "ius", "wen",
};
private readonly IRandomizer _randomizer = Rand.GetRandomizer();
/// <summary>
/// Generates a name which is not yet used, neither within this run nor in the database.
/// </summary>
/// <param name="context">The context used to check name availability.</param>
/// <param name="reserved">The set of names already handed out in this run; the returned name is added to it.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A unique, valid character name.</returns>
public async ValueTask<string> GenerateUniqueAsync(IPlayerContext context, ISet<string> reserved, CancellationToken cancellationToken = default)
{
for (var attempt = 0; attempt < 500; attempt++)
{
var name = this.BuildName(attempt);
if (!reserved.Add(name))
{
continue;
}
var existing = await context.GetAccountByCharacterNameAsync(name, cancellationToken).ConfigureAwait(false);
if (existing is null)
{
return name;
}
}
throw new InvalidOperationException("Could not generate a unique bot character name after many attempts.");
}
private string BuildName(int attempt)
{
var start = Starts.SelectRandom(this._randomizer)!;
var middle = Middles.SelectRandom(this._randomizer)!;
var end = Ends.SelectRandom(this._randomizer)!;
var name = start + middle + end;
// Once the simple name space gets crowded, append a digit to keep finding free names
// without ever exceeding the 10 character limit.
if (attempt > 30)
{
var suffix = (attempt % 10).ToString(CultureInfo.InvariantCulture);
name = (name.Length >= 10 ? name[..9] : name) + suffix;
}
else if (name.Length > 10)
{
name = name[..10];
}
return char.ToUpperInvariant(name[0]) + name[1..].ToLowerInvariant();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,222 @@
// <copyright file="BotPartyHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.Offline;
/// <summary>
/// Lets a server-side bot party up with players who invite it (enabled by
/// <see cref="BotMuHelperSettings.AutoAcceptAnyone"/>): the invitation is accepted after a short
/// human-like delay, and the bot then follows the leader like any party member (see the follow logic
/// in <see cref="BotNavigator"/>) until it gets bored and politely leaves. Safeguards keep it
/// believable and abuse-free: no grouping across an absurd level gap, no acceptance while the bot is
/// on an errand (shopping trip) or has unfinished business (revenge), and the invitation is
/// re-validated when the delay has passed - the inviter may have joined another party or left.
/// </summary>
internal static class BotPartyHandler
{
/// <summary>
/// The maximum difference of the reset-aware effective level (see
/// <see cref="BotResetHandler.GetEffectiveLevel"/>) between the bot and the inviter. Within one
/// reset worth of levels plus some slack, hunting together still makes sense for both; grouping a
/// fresh character with a 15-resets veteran would only be a power-leveling service. On servers
/// without the reset feature the plain levels always lie within this bound, matching OpenMU's own
/// party action, which has no level gate at all.
/// </summary>
private const int MaxEffectiveLevelGap = 500;
/// <summary>Lower bound of the human-like delay before the bot answers an invitation.</summary>
private static readonly TimeSpan MinAcceptDelay = TimeSpan.FromSeconds(2);
/// <summary>Upper bound of the human-like delay before the bot answers an invitation.</summary>
private static readonly TimeSpan MaxAcceptDelay = TimeSpan.FromSeconds(5);
/// <summary>
/// Lower bound of the time the bot stays in a party with a human before it gets bored and leaves.
/// A player who groups a bot gets a companion for a decent hunting session, but not a permanent
/// follower - the bot has its own goals (its resets, its shopping, its own pace).
/// </summary>
private static readonly TimeSpan MinPartyDuration = TimeSpan.FromMinutes(10);
/// <summary>Upper bound of the time the bot stays in a party with a human, see <see cref="MinPartyDuration"/>.</summary>
private static readonly TimeSpan MaxPartyDuration = TimeSpan.FromMinutes(20);
/// <summary>
/// Schedules the acceptance of a party invitation to a bot, if the bot is available for it.
/// Called from the auto-accept criteria of <see cref="MuHelper.PartyRequestHandler"/>.
/// </summary>
/// <param name="receiver">The invited player; only server-side bots schedule an accept.</param>
/// <param name="requester">The player who sent the party request.</param>
/// <param name="acceptDelay">Overrides the human-like random delay (used by tests).</param>
/// <returns>True, if the invitation was taken and will be answered; false, if no criteria matched.</returns>
internal static async ValueTask<bool> TryScheduleAcceptAsync(Player receiver, Player requester, TimeSpan? acceptDelay = null)
{
if (receiver is not OfflinePlayer bot
|| bot.Account?.IsBot != true
|| HasHumanCompanion(bot)
|| bot.PendingPartyInvite is not null)
{
return false;
}
if (bot.IsOnShoppingTrip || bot.HasRevengeIntent || bot.CurrentMiniGame is not null)
{
// Busy - a player in the middle of an errand, a grudge or an event would not group up either.
return false;
}
if (!IsRequesterEligible(bot, requester))
{
return false;
}
var delay = acceptDelay
?? MinAcceptDelay + TimeSpan.FromMilliseconds(Rand.NextInt(0, (int)(MaxAcceptDelay - MinAcceptDelay).TotalMilliseconds + 1));
// Blocks a second concurrent inviter (the request action treats a set requester like a busy
// player) and is cleared again when the invitation is answered or dropped.
bot.LastPartyRequester = requester;
bot.PendingPartyInvite = new PendingPartyInvite(requester, DateTime.UtcNow + delay);
// The same feedback a human invitee's request flow gives, so the inviter knows it went out.
await requester.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.RequestedPlayerForParty), bot.Name).ConfigureAwait(false);
bot.Logger.LogInformation("Bot '{Name}' accepts the party invitation of '{Requester}' in {Delay}.", bot.Name, requester.Name, delay);
return true;
}
/// <summary>
/// Drives the bot's party behavior; called from the bot's regular evaluation tick. Answers a
/// pending invitation once its delay passed, and leaves the party again when the bot got bored
/// of grouping with a human (bot-only parties are exempt - they are managed by the hourly
/// re-formation of <see cref="BotManager"/>).
/// </summary>
/// <param name="bot">The bot.</param>
internal static async ValueTask ProcessAsync(OfflinePlayer bot)
{
if (bot.PendingPartyInvite is { } invite && DateTime.UtcNow >= invite.AcceptAtUtc)
{
bot.PendingPartyInvite = null;
try
{
await AcceptInvitationAsync(bot, invite.Requester).ConfigureAwait(false);
}
finally
{
bot.LastPartyRequester = null;
}
}
if (bot.Party is { } party && HasHumanCompanion(bot))
{
bot.PartyBoredomAtUtc ??= DateTime.UtcNow + MinPartyDuration
+ TimeSpan.FromSeconds(Rand.NextInt(0, (int)(MaxPartyDuration - MinPartyDuration).TotalSeconds + 1));
if (DateTime.UtcNow >= bot.PartyBoredomAtUtc)
{
bot.PartyBoredomAtUtc = null;
bot.Logger.LogInformation("Bot '{Name}' got bored and leaves its party.", bot.Name);
await party.KickMySelfAsync(bot).ConfigureAwait(false);
}
}
else
{
bot.PartyBoredomAtUtc = null;
}
}
/// <summary>
/// Determines whether the bot's party contains a human player (any live member which is not a
/// server-side <see cref="OfflinePlayer"/>).
/// </summary>
/// <param name="bot">The bot.</param>
/// <returns>True, if a human player is in the bot's party.</returns>
internal static bool HasHumanCompanion(Player bot)
{
return bot.Party is { } party
&& party.PartyList.OfType<Player>().Any(member => member is not OfflinePlayer);
}
private static async ValueTask AcceptInvitationAsync(OfflinePlayer bot, Player requester)
{
// Re-validate: between the invitation and this answer, the bot may have joined a human's party
// and the inviter may have died, left the game or joined another party.
if (HasHumanCompanion(bot) || !IsRequesterEligible(bot, requester))
{
bot.Logger.LogInformation("Bot '{Name}' dropped the party invitation of '{Requester}' - the situation changed.", bot.Name, requester.Name);
return;
}
await LeaveBotPartyAsync(bot).ConfigureAwait(false);
if (bot.Party is not null)
{
bot.Logger.LogInformation("Bot '{Name}' could not leave its bot party for '{Requester}'.", bot.Name, requester.Name);
return;
}
bool success;
if (requester.Party is { } requesterParty)
{
if (!Equals(requesterParty.PartyMaster, requester))
{
// The inviter joined another party as a plain member in the meantime; it can no
// longer take the bot in.
return;
}
success = await requesterParty.AddAsync(bot).ConfigureAwait(false);
}
else
{
// Like the regular party response: the requester becomes the master of the new party.
var party = bot.GameContext.PartyManager.CreateParty();
success = await party.AddAsync(requester).ConfigureAwait(false)
&& await party.AddAsync(bot).ConfigureAwait(false);
}
if (success)
{
bot.Logger.LogInformation("Bot '{Name}' joined the party of '{Requester}'.", bot.Name, requester.Name);
}
}
/// <summary>
/// Lets the bot leave the bot-only party it hunts in, so it can join the player who invited it: a
/// living player takes precedence over the bot's own company. When the bot LEADS that party, the
/// group is broken up instead - the engine does not hand the mastership over to another member when
/// the master leaves (it only removes them from the member list), which would leave the remaining
/// bots following a leader who is not in their party anymore. Their next hourly re-formation groups
/// them again (see <see cref="BotManager"/>).
/// </summary>
private static async ValueTask LeaveBotPartyAsync(OfflinePlayer bot)
{
if (bot.Party is not { } party)
{
return;
}
if (Equals(party.PartyMaster, bot))
{
bot.Logger.LogInformation("Bot '{Name}' breaks up its bot party to join a player.", bot.Name);
foreach (var member in party.PartyList.ToList())
{
await party.KickMySelfAsync(member).ConfigureAwait(false);
}
return;
}
await party.KickMySelfAsync(bot).ConfigureAwait(false);
}
private static bool IsRequesterEligible(OfflinePlayer bot, Player requester)
{
if (!requester.IsAlive || requester.PlayerState.CurrentState != PlayerState.EnteredWorld)
{
return false;
}
var levelGap = Math.Abs(BotResetHandler.GetEffectiveLevel(bot) - BotResetHandler.GetEffectiveLevel(requester));
return levelGap <= MaxEffectiveLevelGap;
}
}

View File

@@ -0,0 +1,115 @@
// <copyright file="BotPlayer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Threading;
using MUnique.OpenMU.GameLogic.Offline;
/// <summary>
/// A connection-less bot player. It reuses the whole offline-player intelligence (combat, buffs,
/// healing, pickup) and adds a <see cref="BotNavigator"/> which makes it roam to level-appropriate
/// hunting grounds instead of standing on a fixed spawn position.
/// </summary>
public sealed class BotPlayer : OfflinePlayer
{
/// <summary>
/// After this many AI ticks failing in a row, the bot is considered broken and gets restarted.
/// The engine's attribute system is not thread-safe, and a lost race can corrupt a character's
/// attribute graph for good: every following tick throws, the bot stops playing and floods the log
/// with the same exception until the server restarts. A fresh login rebuilds the graph and heals it,
/// which is exactly what a player would do. The threshold is high enough that a single failing tick
/// (a transient race, a monster which just died) is simply skipped, like before.
/// </summary>
private const int ConsecutiveFailuresUntilRestart = 20;
private BotNavigator? _navigator;
private int _consecutiveTickFailures;
/// <summary>
/// Initializes a new instance of the <see cref="BotPlayer"/> class.
/// </summary>
/// <param name="gameContext">The game context.</param>
public BotPlayer(IGameContext gameContext)
: base(gameContext)
{
}
/// <inheritdoc />
public override bool RespawnAndContinue => true;
/// <summary>
/// Gets or sets a value indicating whether this bot evolved into its master class and still needs
/// the "relog" which mounts the master attributes (see <see cref="BotManager.RestartBotAsync"/>).
/// Set from the bot's own tick, where the evolution runs; acted upon by the maintenance pass, which
/// is the only place allowed to restart a bot (a restart from within the bot's own timer callback
/// would tear down the very loop it runs in).
/// </summary>
public bool AwaitsMasterRestart { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this bot's AI keeps failing and it has to be restarted
/// (see <see cref="ConsecutiveFailuresUntilRestart"/>). Acted upon by the maintenance pass, which is
/// the only place allowed to restart a bot.
/// </summary>
public bool AwaitsFaultRestart { get; set; }
/// <inheritdoc />
public override async ValueTask StopAsync()
{
await this.StopNavigatorAsync().ConfigureAwait(false);
await base.StopAsync().ConfigureAwait(false);
}
/// <inheritdoc />
internal override void OnAiTickSucceeded()
{
if (this._consecutiveTickFailures > 0)
{
Interlocked.Exchange(ref this._consecutiveTickFailures, 0);
}
}
/// <inheritdoc />
internal override void OnAiTickFailed()
{
if (Interlocked.Increment(ref this._consecutiveTickFailures) == ConsecutiveFailuresUntilRestart)
{
// Deliberately '==', not '>=': this arms the restart exactly once, at the tick that crosses
// the threshold, so the log gets one line and the flag is raised once. Further failures keep
// incrementing the counter (which stays above the threshold) but don't re-fire; the
// maintenance pass consumes AwaitsFaultRestart and the restart resets the counter to 0.
this.Logger.LogWarning(
"Bot '{Name}' failed {Count} AI ticks in a row and gets restarted to heal it.",
this.Name,
ConsecutiveFailuresUntilRestart);
this.AwaitsFaultRestart = true;
}
}
/// <inheritdoc />
protected override void StartIntelligence()
{
base.StartIntelligence();
this._navigator = new BotNavigator(this);
this._navigator.Start();
}
/// <inheritdoc />
protected override async ValueTask DisposeAsyncCore()
{
await this.StopNavigatorAsync().ConfigureAwait(false);
await base.DisposeAsyncCore().ConfigureAwait(false);
}
private async ValueTask StopNavigatorAsync()
{
if (this._navigator is { } navigator)
{
this._navigator = null;
await navigator.DisposeAsync().ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,433 @@
// <copyright file="BotProgression.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.Attributes;
/// <summary>
/// The shared progression rules of server-side bots: how a bot of a given class invests its stat
/// points, and which skills it may learn. Used by the <see cref="BotGenerator"/> when a bot is
/// created and by the <see cref="BotSkillProgressionPlugIn"/> when it levels up during play, so a
/// freshly generated bot and one that grew to the same level in-game end up with the same build.
/// </summary>
internal static class BotProgression
{
/// <summary>
/// The character level at which a bot changes into its second-generation class (e.g. Dark Knight
/// to Blade Knight), the way a player completes the class-change quest. The quest itself boils down
/// to exactly this assignment (see <c>QuestCompletionAction</c>), so bots take the direct route.
/// </summary>
public const int ClassEvolutionLevel = 200;
/// <summary>
/// The character class numbers from the game's data model (<c>CharacterClassNumber</c> lives in the
/// initialization assembly which GameLogic does not reference, so the relevant values are mirrored here).
/// </summary>
private const byte DarkWizardNumber = 0;
private const byte SoulMasterNumber = 2;
private const byte GrandMasterNumber = 3;
private const byte DarkKnightNumber = 4;
private const byte BladeKnightNumber = 6;
private const byte BladeMasterNumber = 7;
private const byte FairyElfNumber = 8;
private const byte MuseElfNumber = 10;
private const byte HighElfNumber = 11;
private const byte MagicGladiatorNumber = 12;
private const byte DuelMasterNumber = 13;
private const byte DarkLordNumber = 16;
private const byte LordEmperorNumber = 17;
private const byte SummonerNumber = 20;
private const byte BloodySummonerNumber = 22;
private const byte DimensionMasterNumber = 23;
private const byte RageFighterNumber = 24;
private const byte FistMasterNumber = 25;
/// <summary>
/// The share of each invested batch that goes into vitality on reset-meta servers, until the bot's
/// personal <see cref="GetVitalityTarget"/> is reached (out of a nominal weight total of ~100).
/// </summary>
private const int ResetMetaVitalityWeight = 5;
/// <summary>
/// The base classes which evolve into a second-generation class at <see cref="ClassEvolutionLevel"/>:
/// Dark Wizard, Dark Knight, Fairy Elf and Summoner. The Magic Gladiator, Dark Lord and Rage Fighter
/// have no second generation - their next class is the level-400 master evolution, out of bot scope.
/// </summary>
private static readonly byte[] EvolvableClassNumbers = [0, 4, 8, 20];
/// <summary>
/// Skills of the buff type which must never enter a bot's auto-buff rotation: the summoner's
/// enemy debuffs (Sleep/Weakness/Innovation - the offline buff handler casts buffs on SELF, so the
/// bot would put itself to sleep), and Defense (18), which players get from equipping a shield
/// rather than learning it.
/// </summary>
private static readonly short[] ExcludedBuffSkillNumbers = [18, 219, 221, 222];
/// <summary>
/// Gets the class the character evolves into at <see cref="ClassEvolutionLevel"/>, or null when the
/// class has no (in-scope) evolution.
/// </summary>
/// <param name="characterClass">The character class.</param>
public static CharacterClass? GetEvolutionTarget(CharacterClass characterClass)
{
return EvolvableClassNumbers.Contains(characterClass.Number)
? characterClass.NextGenerationClass
: null;
}
/// <summary>
/// Gets the master class the character evolves into at the game's maximum level (the
/// third-generation evolution the level-400 master quests perform), or null when the current class
/// has none. Unlike <see cref="GetEvolutionTarget"/> this applies to all classes: the
/// second-generation classes evolve into their masters (Blade Knight -> Blade Master, ...), and
/// Magic Gladiator, Dark Lord and Rage Fighter - which have no second generation - evolve directly
/// (-> Duel Master, Lord Emperor, Fist Master). When and whether a bot takes this step is decided
/// by <see cref="BotMasterHandler.IsMasterEvolutionDue"/>.
/// </summary>
/// <param name="characterClass">The character class.</param>
public static CharacterClass? GetMasterEvolutionTarget(CharacterClass characterClass)
{
return characterClass is { IsMasterClass: false, NextGenerationClass: { IsMasterClass: true } masterClass }
? masterClass
: null;
}
/// <summary>
/// How a bot invests its stat points, per class and per bot, in one of two meta profiles chosen
/// by the server type (see <see cref="BotResetHandler.GetResetConfiguration"/> at the call sites):
/// <list type="bullet">
/// <item><b>Reset meta</b> (reset feature enabled) - modeled on the actual endgame characters of a
/// reset server: everything goes into the class's combat stats (shield and agility-based defense do
/// the tanking there), vitality only receives a token share until the bot's personal
/// <see cref="GetVitalityTarget"/> is reached (enforce via <see cref="SplitPoints"/> capacities).</item>
/// <item><b>Classic meta</b> (no reset feature) - the builds of community stat guides for classic
/// servers, where point pools are small and vitality is a plain percentage of the build.</item>
/// </list>
/// Where a class has two established builds (knight agility/PK, gladiator warrior/mage, elf
/// archer/supporter), each bot picks one deterministically from its character name, so the
/// population is diverse but every bot keeps the same build across sessions and re-invests
/// consistently after each reset. The first entry is always the build's primary stat - it absorbs
/// rounding remainders and overflow from capped stats.
/// </summary>
/// <param name="characterClass">The character class.</param>
/// <param name="characterName">The character name; decides the build variant for two-build classes.</param>
/// <param name="resetMeta">Whether the reset-server meta profile applies.</param>
public static IReadOnlyList<(AttributeDefinition Stat, int Weight)> GetStatWeights(CharacterClass characterClass, string characterName, bool resetMeta)
{
// Stable across processes (string.GetHashCode is randomized per run, which would re-spec
// the bot on every server restart).
var variant = characterName.Aggregate(0, (acc, c) => acc + c) % 2;
var vit = Stats.BaseVitality;
var str = Stats.BaseStrength;
var agi = Stats.BaseAgility;
var ene = Stats.BaseEnergy;
var cmd = Stats.BaseLeadership;
if (resetMeta)
{
const int v = ResetMetaVitalityWeight;
return characterClass.Number switch
{
DarkKnightNumber or BladeKnightNumber or BladeMasterNumber => variant == 0
? new[] { (str, 59), (agi, 39), (ene, 2), (vit, v) }
: new[] { (str, 52), (agi, 35), (ene, 13), (vit, v) },
DarkWizardNumber or SoulMasterNumber or GrandMasterNumber =>
new[] { (ene, 49), (agi, 47), (str, 4), (vit, v) },
FairyElfNumber or MuseElfNumber or HighElfNumber =>
new[] { (agi, 67), (ene, 27), (str, 6), (vit, v) },
MagicGladiatorNumber or DuelMasterNumber => variant == 0
? new[] { (str, 57), (agi, 26), (ene, 17), (vit, v) }
: new[] { (ene, 66), (agi, 18), (str, 16), (vit, v) },
DarkLordNumber or LordEmperorNumber =>
new[] { (str, 54), (cmd, 23), (agi, 18), (ene, 5), (vit, v) },
SummonerNumber or BloodySummonerNumber or DimensionMasterNumber =>
new[] { (ene, 70), (agi, 18), (str, 12), (vit, v) },
RageFighterNumber or FistMasterNumber =>
new[] { (str, 64), (agi, 18), (ene, 18), (vit, v) },
_ when GetMainDamageStat(characterClass) == str =>
new[] { (str, 60), (agi, 35), (vit, v) },
_ => new[] { (GetMainDamageStat(characterClass), 65), (agi, 30), (vit, v) },
};
}
return characterClass.Number switch
{
DarkKnightNumber or BladeKnightNumber or BladeMasterNumber => variant == 0
? new[] { (str, 62), (agi, 26), (vit, 8), (ene, 4) }
: new[] { (str, 50), (vit, 28), (agi, 18), (ene, 4) },
DarkWizardNumber or SoulMasterNumber or GrandMasterNumber =>
new[] { (ene, 66), (vit, 22), (agi, 8), (str, 4) },
FairyElfNumber or MuseElfNumber or HighElfNumber => variant == 0
? new[] { (agi, 62), (vit, 23), (ene, 10), (str, 5) }
: new[] { (ene, 65), (vit, 22), (agi, 8), (str, 5) },
MagicGladiatorNumber or DuelMasterNumber => variant == 0
? new[] { (str, 57), (agi, 22), (vit, 15), (ene, 6) }
: new[] { (ene, 58), (vit, 26), (agi, 11), (str, 5) },
DarkLordNumber or LordEmperorNumber =>
new[] { (str, 38), (cmd, 30), (vit, 22), (agi, 8), (ene, 2) },
SummonerNumber or BloodySummonerNumber or DimensionMasterNumber =>
new[] { (ene, 64), (vit, 24), (agi, 8), (str, 4) },
RageFighterNumber or FistMasterNumber =>
new[] { (str, 45), (vit, 35), (ene, 20) },
_ => new[] { (GetMainDamageStat(characterClass), 50), (vit, 50) },
};
}
/// <summary>
/// The bot's personal vitality target on reset-meta servers: how many points it invests into
/// vitality over its whole career (100..500, rolled deterministically from the character name,
/// so the population gets a natural spread from glassy to sturdy and every bot keeps its roll
/// across restarts and resets). The endgame players such servers breed leave vitality almost
/// untouched - shield and agility-based defense tank instead - so the target is intentionally low.
/// </summary>
/// <param name="characterName">The character name.</param>
public static int GetVitalityTarget(string characterName)
{
var sum = characterName.Aggregate(0, (acc, c) => acc + c);
return 100 + ((sum * 7919) % 401);
}
/// <summary>
/// Splits the given points proportionally to the class's stat weights, returning whole-point
/// amounts which sum up to <paramref name="points"/> - unless capacities cut it short. The
/// optional <paramref name="capacityOf"/> callback limits how many points a stat may still take
/// (its <see cref="AttributeDefinition.MaximumValue"/> on fun servers, the vitality target on
/// reset-meta servers); a filled stat drops out of the split and its share flows to the remaining
/// stats over subsequent rounds. When every stat is full, the rest of the points stay unassigned,
/// like for a maxed-out human character.
/// </summary>
/// <param name="points">The number of points to split.</param>
/// <param name="weights">The stat weights of the class.</param>
/// <param name="capacityOf">Optionally resolves how many more points a stat can take; null means unlimited.</param>
public static IEnumerable<(AttributeDefinition Stat, int Amount)> SplitPoints(
int points,
IReadOnlyList<(AttributeDefinition Stat, int Weight)> weights,
Func<AttributeDefinition, long>? capacityOf = null)
{
if (points <= 0 || weights.Count == 0)
{
yield break;
}
var allocated = new int[weights.Count];
var capacity = new long[weights.Count];
for (var i = 0; i < weights.Count; i++)
{
capacity[i] = Math.Max(0, capacityOf?.Invoke(weights[i].Stat) ?? long.MaxValue);
}
var remaining = points;
while (remaining > 0)
{
var activeTotalWeight = 0;
var firstActive = -1;
for (var i = 0; i < weights.Count; i++)
{
if (weights[i].Weight > 0 && allocated[i] < capacity[i])
{
activeTotalWeight += weights[i].Weight;
if (firstActive < 0)
{
firstActive = i;
}
}
}
if (activeTotalWeight <= 0)
{
break; // every stat is at its capacity - the rest stays unspent.
}
var assignedThisRound = 0;
for (var i = 0; i < weights.Count; i++)
{
if (weights[i].Weight <= 0 || allocated[i] >= capacity[i])
{
continue;
}
var share = (int)Math.Min((long)remaining * weights[i].Weight / activeTotalWeight, capacity[i] - allocated[i]);
allocated[i] += share;
assignedThisRound += share;
}
if (assignedThisRound == 0)
{
// Rounding tail (fewer points left than active stats): the primary stat takes it.
var tail = (int)Math.Min(remaining, capacity[firstActive] - allocated[firstActive]);
allocated[firstActive] += tail;
assignedThisRound = tail;
if (assignedThisRound == 0)
{
break;
}
}
remaining -= assignedThisRound;
}
for (var i = 0; i < weights.Count; i++)
{
if (allocated[i] > 0)
{
yield return (weights[i].Stat, allocated[i]);
}
}
}
/// <summary>
/// Determines whether the skill is one a bot may learn: an actual attack skill, or a self/party
/// buff or heal with a magic effect (which the offline buff/heal handlers know how to cast).
/// Passive boosts, event skills, enemy debuffs and utility skills are left out.
/// </summary>
/// <param name="skill">The skill to check.</param>
public static bool IsBotLearnableSkill(Skill skill)
{
if (skill.MasterDefinition is not null)
{
// Master skills are never learned for free - they cost the master points earned per master
// level and go through the regular action (see BotMasterHandler), like for a human player.
return false;
}
if (skill.AttackDamage > 0
&& skill.SkillType is SkillType.DirectHit
or SkillType.AreaSkillAutomaticHits
or SkillType.AreaSkillExplicitHits
or SkillType.AreaSkillExplicitTarget)
{
return true;
}
return skill.SkillType is SkillType.Buff or SkillType.Regeneration
&& skill.MagicEffectDef is not null
&& !ExcludedBuffSkillNumbers.Contains(skill.Number);
}
/// <summary>
/// Determines whether the character meets the skill's learn requirements (the same ones the game
/// enforces when casting, e.g. total energy for wizard spells or character level for knight skills).
/// <paramref name="getAttributeValue"/> resolves an attribute's current value; returning null means
/// the attribute is unknown in the caller's context, which conservatively fails the requirement.
/// </summary>
/// <param name="skill">The skill whose requirements are checked.</param>
/// <param name="getAttributeValue">Resolves an attribute's current value; null means the attribute is unknown.</param>
public static bool MeetsRequirements(Skill skill, Func<AttributeDefinition, float?> getAttributeValue)
{
foreach (var requirement in skill.Requirements)
{
if (requirement.Attribute is not { } attribute)
{
continue;
}
if (getAttributeValue(attribute) is not { } value || value < requirement.MinimumValue)
{
return false;
}
}
return true;
}
/// <summary>
/// Maps a "total" attribute (used by skill requirements) to the base stat a generated character
/// actually has, so requirements can be evaluated before the character was ever composed at runtime.
/// Returns null for attributes that have no base-stat counterpart.
/// </summary>
/// <param name="attribute">The "total" attribute to map.</param>
public static AttributeDefinition? TotalToBaseStat(AttributeDefinition attribute)
{
if (attribute == Stats.TotalEnergy)
{
return Stats.BaseEnergy;
}
if (attribute == Stats.TotalStrength)
{
return Stats.BaseStrength;
}
if (attribute == Stats.TotalAgility)
{
return Stats.BaseAgility;
}
if (attribute == Stats.TotalVitality)
{
return Stats.BaseVitality;
}
if (attribute == Stats.TotalLeadership)
{
return Stats.BaseLeadership;
}
if (attribute == Stats.Level)
{
return Stats.Level;
}
return null;
}
/// <summary>
/// Determines whether a weapon of the given item group fits the fighting style of this bot's BUILD:
/// archers use bows, casters staves, everyone else melee weapons. The build decides, not just the
/// class - a Magic Gladiator specced into energy (see the variants in <see cref="GetStatWeights"/>)
/// is a caster and must get a staff, while its strength-specced sibling wants a blade; deciding by
/// the class's base attributes alone handed both of them swords. Classes whose base attributes make
/// them archers (the elves) keep their bow in every build - it is the only weapon they can wield.
/// Used both for the starter gear (<see cref="BotGenerator"/>) and for later upgrades
/// (<see cref="BotEquipmentHandler"/>), so an elf never swaps its bow for a random axe it happens to
/// be qualified for (which would also displace its arrows).
/// </summary>
/// <param name="characterClass">The character class.</param>
/// <param name="characterName">The character name; decides the build variant, see <see cref="GetStatWeights"/>.</param>
/// <param name="resetMeta">Whether the reset-server meta profile applies.</param>
/// <param name="itemGroup">The item group of the weapon.</param>
public static bool IsPreferredWeaponGroup(CharacterClass characterClass, string characterName, bool resetMeta, byte itemGroup)
{
const byte maxMeleeGroup = 3;
const byte bowGroup = 4;
const byte staffGroup = 5;
float ClassStat(AttributeDefinition attribute)
=> characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == attribute)?.BaseValue ?? 0f;
var strength = ClassStat(Stats.BaseStrength);
var agility = ClassStat(Stats.BaseAgility);
var energy = ClassStat(Stats.BaseEnergy);
if (agility > strength && agility > energy)
{
return itemGroup == bowGroup;
}
// The build's primary stat (the first weight, see GetStatWeights) tells a caster from a fighter;
// the class fallback covers classes without an energy build of their own.
var primaryStat = GetStatWeights(characterClass, characterName, resetMeta)[0].Stat;
if (primaryStat == Stats.BaseEnergy || energy > strength)
{
return itemGroup == staffGroup;
}
return itemGroup <= maxMeleeGroup;
}
private static AttributeDefinition GetMainDamageStat(CharacterClass characterClass)
{
return characterClass.StatAttributes
.Where(a => a.Attribute == Stats.BaseStrength
|| a.Attribute == Stats.BaseAgility
|| a.Attribute == Stats.BaseEnergy
|| a.Attribute == Stats.BaseLeadership)
.OrderByDescending(a => a.BaseValue)
.Select(a => a.Attribute!)
.FirstOrDefault() ?? Stats.BaseStrength;
}
}

View File

@@ -0,0 +1,69 @@
// <copyright file="BotPvpRules.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
/// <summary>
/// The single source of truth for when a server-side bot may attack a player.
/// </summary>
/// <remarks>
/// Invariant: a bot must never escalate its own <see cref="HeroState"/>. A bot which turns outlaw
/// is a broken toy - it can be killed penalty-free forever, loses the warp command, and visibly
/// marks itself as a misbehaving AI. The game escalates the killer's hero state (see
/// <c>Player.AfterKilledPlayerAsync</c>) unless the victim is already an outlaw or the kill happened
/// in active self-defense (duels and rival-guild wars are exempt as well, but bots have neither),
/// so those two cases are exactly what this rule allows.
/// The bot's grudge memory (<see cref="Offline.OfflinePlayer.RecentAggressor"/>, ~5 minutes, and the
/// revenge march after a death) is deliberately longer than the game's self-defense window: the
/// grudge only decides WHOM the bot prioritizes and where it walks; whether it may actually strike
/// is decided here, per attack, against the game's own rules.
/// </remarks>
public static class BotPvpRules
{
/// <summary>
/// How much of the self-defense window must still remain for an attack to count as safe. The
/// legality check runs when the attack is issued, but the kill (and with it the game's own
/// self-defense evaluation) can land moments later - without a margin, a final blow right at
/// the window's edge would escalate the bot's hero state after all. While the player keeps
/// attacking, every damaging hit renews the window, so the margin never interrupts an ongoing fight.
/// </summary>
private static readonly TimeSpan SelfDefenseSafetyMargin = TimeSpan.FromSeconds(3);
/// <summary>
/// Determines whether the bot may legally attack the given player, i.e. without any risk of
/// escalating the bot's own <see cref="HeroState"/>.
/// </summary>
/// <param name="bot">The bot (or offline player) which wants to attack.</param>
/// <param name="target">The player it wants to attack.</param>
/// <returns><c>true</c> if attacking is free of PK consequences; otherwise, <c>false</c>.</returns>
public static bool IsLegalPvpTarget(Player bot, Player target)
{
// A running mini game with free player killing (Chaos Castle): every fellow participant
// is fair game - such kills never escalate the hero state (see Player.OnDeathAsync), the
// game's self-defense bookkeeping doesn't even track them. Gated on the running state, so
// bots don't swing at players during the countdown before the event starts.
if (!ReferenceEquals(bot, target)
&& bot.CurrentMiniGame is { AllowPlayerKilling: true, IsEventRunning: true } miniGame
&& ReferenceEquals(target.CurrentMiniGame, miniGame))
{
return true;
}
// Outlaws are fair game for everyone - killing them never escalates the killer's state.
if (target.SelectedCharacter?.State >= HeroState.PlayerKiller1stStage)
{
return true;
}
// Active self-defense: the target attacked this bot recently (SelfDefenseState is keyed
// (attacker, defender) and renewed on every damaging hit by the SelfDefensePlugIn).
if (bot.GameContext.SelfDefenseState.TryGetValue((target, bot), out var timeout)
&& timeout > DateTime.UtcNow.Add(SelfDefenseSafetyMargin))
{
return true;
}
return false;
}
}

View File

@@ -0,0 +1,218 @@
// <copyright file="BotResetHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Resets;
/// <summary>
/// Performs character resets for bots on servers where the <see cref="ResetFeaturePlugIn"/> is enabled,
/// and provides the reset-aware effective level used by the bot logic. Everything is driven by the
/// server's actual <see cref="ResetConfiguration"/> (and <see cref="ResetProgressionCalculator"/>), so
/// bots follow the same reset rules as the human players of that particular server; when the feature
/// is disabled, none of this changes bot behavior at all.
/// </summary>
/// <remarks>
/// The reset itself mirrors the effect of <see cref="ResetCharacterAction"/>, with two deliberate
/// differences for the connection-less bot ghosts: the costs (zen, reset items) are skipped by default,
/// because bots don't take part in the economy the costs are balanced for (see
/// <see cref="BotConfiguration.BotsPayResetCosts"/>), and the <see cref="ResetConfiguration.LogOut"/>
/// step is replaced by continuing in place - a bot has no client to log out.
/// </remarks>
internal static class BotResetHandler
{
/// <summary>
/// Gets the server's reset configuration, or null when the reset feature is not enabled.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <returns>The reset configuration, or null.</returns>
public static ResetConfiguration? GetResetConfiguration(IGameContext gameContext)
=> gameContext.FeaturePlugIns.GetPlugIn<ResetFeaturePlugIn>()?.Configuration;
/// <summary>
/// Gets the player's effective level for bot decisions: on a reset server a freshly reset
/// character is back at the configured <see cref="ResetConfiguration.LevelAfterReset"/> but
/// fights with the accumulated power of all its resets, so the
/// plain level would misjudge it everywhere (target safety, map choice, party matching). Each
/// reset counts as the level span it took (<see cref="ResetConfiguration.RequiredLevel"/>).
/// Master levels count on top (like the game's own total level), so an evolved master keeps
/// being judged stronger than a plain level-capped character.
/// Without the reset feature this is the character level plus the master level.
/// </summary>
/// <param name="player">The player.</param>
/// <returns>The effective level.</returns>
public static int GetEffectiveLevel(Player player)
{
var level = (int)(player.Attributes?[Stats.Level] ?? 1)
+ (int)(player.Attributes?[Stats.MasterLevel] ?? 0f);
if (GetResetConfiguration(player.GameContext) is not { } configuration)
{
return level;
}
var resets = (int)(player.Attributes?[Stats.Resets] ?? 0f);
return (resets * Math.Max(0, configuration.RequiredLevel)) + level;
}
/// <summary>
/// Determines whether the bot is currently eligible for a reset: the reset feature is enabled,
/// the required level is reached and the reset limit (if any) is not yet exhausted.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="configuration">The reset configuration.</param>
/// <returns>True, if the bot can reset now.</returns>
public static bool IsResetDue(Player player, ResetConfiguration configuration)
{
if (player.Attributes is not { } attributes)
{
return false;
}
if (player.Level < configuration.RequiredLevel)
{
return false;
}
var nextResetCount = (int)attributes[Stats.Resets] + 1;
return configuration.ResetLimit is not > 0 || nextResetCount <= configuration.ResetLimit;
}
/// <summary>
/// Resets the bot character, mirroring the effect of <see cref="ResetCharacterAction"/>: the reset
/// count goes up, the level drops to <see cref="ResetConfiguration.LevelAfterReset"/>, the stats and
/// level-up points follow the server's configuration, and with <see cref="ResetConfiguration.MoveHome"/>
/// the bot warps back to its class home town. Afterwards the regular level-up progression is fired,
/// so the bot immediately re-invests the granted point pool according to its class build - until that
/// runs, the damage-based target safety keeps the temporarily weak bot away from monsters it can no
/// longer handle.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="configuration">The reset configuration.</param>
/// <param name="payCosts">Whether the configured costs (zen, reset items) are consumed like for a human player.</param>
/// <returns>True, if the reset was performed.</returns>
public static async ValueTask<bool> TryResetAsync(OfflinePlayer player, ResetConfiguration configuration, bool payCosts)
{
if (player.Attributes is not { } attributes
|| player.SelectedCharacter is not { CharacterClass: not null } character
|| !IsResetDue(player, configuration))
{
return false;
}
var resetProgression = ResetProgressionCalculator.Calculate(
(int)attributes[Stats.Resets],
(int)attributes[Stats.PointsPerReset],
configuration);
if (payCosts && !await TryConsumeCostsAsync(player, configuration, resetProgression).ConfigureAwait(false))
{
return false;
}
attributes[Stats.Resets] = resetProgression.NextResetCount;
attributes[Stats.Level] = configuration.LevelAfterReset;
character.Experience = 0;
if (configuration.ResetStats)
{
character.CharacterClass!.StatAttributes
.Where(s => s.IncreasableByPlayer)
.ForEach(s => attributes[s.Attribute] = s.BaseValue);
}
if (configuration.ReplacePointsPerReset)
{
character.LevelUpPoints = resetProgression.TotalPointsAfterReset;
}
else
{
character.LevelUpPoints += resetProgression.PointsForReset;
}
player.Logger.LogInformation(
"Bot '{Name}' performed reset {ResetCount} and got {Points} points to invest.",
player.Name,
resetProgression.NextResetCount,
character.LevelUpPoints);
if (configuration.MoveHome)
{
await MoveHomeAsync(player).ConfigureAwait(false);
}
// No LogOut for the connection-less ghost - instead re-run the level-up progression, which
// invests the whole granted point pool and re-checks the learnable skills (queued into the
// bot's AI tick by BotSkillProgressionPlugIn, exactly like for an earned level-up).
player.GameContext.PlugInManager.GetPlugInPoint<ICharacterLevelUpPlugIn>()?.CharacterLeveledUp(player);
try
{
// Persist right away instead of waiting for the periodic save - losing a whole performed
// reset to a crash within that window would hurt far more than ordinary hunting progress.
await player.SaveProgressAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after its reset; the periodic save will retry.", player.Name);
}
return true;
}
/// <summary>
/// Consumes the configured reset costs like <see cref="ResetCharacterAction"/> does for a human
/// player: the required zen and the required amount of the configured reset item. Only used when
/// <see cref="BotConfiguration.BotsPayResetCosts"/> is enabled.
/// </summary>
private static async ValueTask<bool> TryConsumeCostsAsync(OfflinePlayer player, ResetConfiguration configuration, ResetProgression resetProgression)
{
IList<Item> requiredItems = [];
if (resetProgression.RequiredItemAmount > 0 && configuration.RequiredResetItem is { } requiredDefinition)
{
requiredItems = player.Inventory?.Items
.Where(item => item.Definition is { } definition
&& definition.Group == requiredDefinition.Group
&& definition.Number == requiredDefinition.Number)
.Take(resetProgression.RequiredItemAmount)
.ToList() ?? [];
if (requiredItems.Count < resetProgression.RequiredItemAmount)
{
return false;
}
}
if (player.Money < resetProgression.RequiredZen
|| (resetProgression.RequiredZen > 0 && !player.TryRemoveMoney(resetProgression.RequiredZen)))
{
return false;
}
foreach (var item in requiredItems)
{
await player.DestroyInventoryItemAsync(item).ConfigureAwait(false);
}
return true;
}
/// <summary>
/// Warps the bot to a spawn gate of its class home map, the live-ghost equivalent of the
/// position rewrite <see cref="ResetCharacterAction"/> performs before logging a player out.
/// </summary>
private static async ValueTask MoveHomeAsync(OfflinePlayer player)
{
var homeMapDefinition = player.SelectedCharacter?.CharacterClass?.HomeMap;
if (homeMapDefinition is null
|| await player.GameContext.GetMapAsync((ushort)homeMapDefinition.Number).ConfigureAwait(false) is not { } homeMap
|| homeMap.Definition.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is not { } spawnGate)
{
return;
}
await player.WarpToAsync(spawnGate).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,40 @@
// <copyright file="BotRevengePlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Notes when a (human) player kills a server-side bot, so the bot can march back to the place of
/// its death after respawning and take revenge on the killer. A bot which shrugs off being killed
/// and calmly heads for the next hunting ground is an obvious bot giveaway - a real player comes
/// back angry. The return march happens in the <see cref="BotNavigator"/>; the counter-attack in
/// the offline <see cref="CombatHandler"/>, whose re-armed aggressor memory keeps the killer
/// prioritized - struck only once the game's own rules make it legal (see <see cref="BotPvpRules"/>).
/// </summary>
[PlugIn]
[Display(Name = "Bot revenge", Description = "Makes server-side bots return to their death site and take revenge on the player who killed them.")]
[Guid("29B871B0-FBCF-44D4-A677-8A9832AAC193")]
public class BotRevengePlugIn : IAttackableGotKilledPlugIn
{
/// <inheritdoc />
public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer)
{
if (killed is OfflinePlayer bot
&& bot.Account?.IsBot == true
&& bot.CurrentMiniGame is null // a death in an event (Chaos Castle) is part of the game, not a wrong to avenge
&& killer is Player killerPlayer
&& killerPlayer is not OfflinePlayer
&& !ReferenceEquals(killerPlayer, killed))
{
bot.RegisterDeathByPlayer(killerPlayer);
}
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,36 @@
// <copyright file="BotSelfDefensePlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Notes when a (human) player attacks a server-side bot, so the bot's combat AI can defend itself.
/// Without this, a bot placidly keeps farming monsters while a player kills it - the most obviously
/// bot-like behavior an observer can trigger. The actual counter-attack happens in the offline
/// <see cref="CombatHandler"/>, which prioritizes a recent aggressor over its monster targets.
/// </summary>
[PlugIn]
[Display(Name = "Bot self defense", Description = "Makes server-side bots fight back when a player attacks them.")]
[Guid("7E2B9C41-5A8D-4F36-B190-3D6E84C7F215")]
public class BotSelfDefensePlugIn : IAttackableGotHitPlugIn
{
/// <inheritdoc />
public void AttackableGotHit(IAttackable attackable, IAttacker attacker, HitInfo hitInfo)
{
if (attackable is OfflinePlayer bot
&& bot.Account?.IsBot == true
&& bot.CurrentMiniGame is null // event fights (Chaos Castle) leave no grudge outside
&& attacker is Player aggressor
&& aggressor is not OfflinePlayer
&& !ReferenceEquals(aggressor, attackable))
{
bot.RegisterAggressor(aggressor);
}
}
}

View File

@@ -0,0 +1,186 @@
// <copyright file="BotServerPartition.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// Splits the bot population over the game servers of the deployment, so a server does not animate the
/// whole population by itself: bots count towards the player count of their server, and a server which
/// is full turns real clients away - the bots would lock the players out of the game.
/// <para>
/// Which accounts a server animates is a pure function of the account index and the SET of configured
/// game servers, so every server computes the same answer without asking the others - no coordination,
/// no shared state, and it holds in a deployment where each game server is its own process. The share
/// of a server is proportional to its capacity, and only a part of that capacity
/// (<see cref="BotConfiguration.BotCapacityPercent"/>) is handed to the bots: the rest stays reserved
/// for real players, who must never be denied a slot by a bot.
/// </para>
/// <para>
/// The split is computed when the server starts. Adding a game server to a running deployment therefore
/// takes a restart before the population spreads onto it; that is deliberate. Moving the ownership of a
/// bot between two RUNNING servers would mean one server animating an account the other one is still
/// animating - the very cross-context situation which corrupts a character.
/// </para>
/// </summary>
internal sealed class BotServerPartition
{
private BotServerPartition(int firstAccount, int accountCount, bool isGenerator)
{
this.FirstAccount = firstAccount;
this.AccountCount = accountCount;
this.IsGenerator = isGenerator;
}
/// <summary>
/// Gets the one-based index of the first bot account this server animates.
/// </summary>
public int FirstAccount { get; }
/// <summary>
/// Gets the number of bot accounts this server animates.
/// </summary>
public int AccountCount { get; }
/// <summary>
/// Gets a value indicating whether this server generates the bot population. Exactly one server
/// does it (the one which animates the first account), so the generation of the accounts - and of
/// their unique character names - never runs twice at the same time. The other servers simply find
/// their accounts once they exist; until then, their spawns are retried by the maintenance pass.
/// </summary>
public bool IsGenerator { get; }
/// <summary>
/// Determines the share of the bot population which the given game server animates.
/// </summary>
/// <param name="gameContext">The context of the game server which asks.</param>
/// <param name="configuration">The bot configuration.</param>
/// <param name="logger">The logger.</param>
/// <returns>The share of this server.</returns>
public static async ValueTask<BotServerPartition> CreateAsync(IGameContext gameContext, BotConfiguration configuration, ILogger logger)
{
var requestedAccounts = Math.Max(configuration.NumberOfAccounts, 0);
var charactersPerAccount = configuration.GetEffectiveCharactersPerAccount();
var capacities = await GetAccountCapacitiesAsync(gameContext, configuration, charactersPerAccount, logger).ConfigureAwait(false);
if (gameContext is not IGameServerContext serverContext || capacities.Count == 0)
{
// A deployment we cannot split (no server definitions readable, or a context which is not a
// game server, e.g. in tests): behave exactly like before - this server animates everything.
return new BotServerPartition(1, requestedAccounts, true);
}
var (partition, assignedAccounts) = Split(capacities, serverContext.Id, requestedAccounts);
if (assignedAccounts < requestedAccounts)
{
logger.LogWarning(
"The bot population does not fit: {Requested} account(s) configured, but only {Fitting} fit into {Percent}% of the game servers' capacity. {Dropped} account(s) stay offline - raise the servers' maximum player count, the bot capacity share, or lower the number of accounts.",
requestedAccounts,
assignedAccounts,
configuration.GetEffectiveBotCapacityPercent(),
requestedAccounts - assignedAccounts);
}
logger.LogInformation(
"This game server ({ServerId}) animates {Count} bot account(s) ({First}..{Last}) of {Requested}.",
serverContext.Id,
partition.AccountCount,
partition.AccountCount == 0 ? 0 : partition.FirstAccount,
partition.AccountCount == 0 ? 0 : partition.FirstAccount + partition.AccountCount - 1,
requestedAccounts);
return partition;
}
/// <summary>
/// Determines whether this server animates the bot account with the given one-based index.
/// </summary>
/// <param name="accountIndex">The one-based bot account index.</param>
public bool Owns(int accountIndex)
=> accountIndex >= this.FirstAccount && accountIndex < this.FirstAccount + this.AccountCount;
/// <summary>
/// Hands the accounts to the servers, each getting a share PROPORTIONAL to its capacity: the pure
/// decision behind <see cref="CreateAsync"/>. Every server runs it over the same list and gets the
/// same answer, which is what makes the split need no coordination at all.
/// <para>
/// Proportional, not first-come: filling one server to the brim before using the next would leave the
/// added server empty until the first one overflows - and a player on it would meet nobody. The bots
/// are there to populate the world, so they spread over the servers the players can choose from.
/// </para>
/// </summary>
/// <param name="capacities">How many accounts each game server may animate, ordered by server id.</param>
/// <param name="serverId">The id of the server which asks.</param>
/// <param name="requestedAccounts">The configured number of bot accounts.</param>
/// <returns>The share of the asking server, and how many accounts fit into the deployment at all.</returns>
internal static (BotServerPartition Partition, int AssignedAccounts) Split(
IEnumerable<(byte ServerId, int Capacity)> capacities,
byte serverId,
int requestedAccounts)
{
var servers = capacities.Where(c => c.Capacity > 0).ToList();
var totalCapacity = servers.Sum(server => (long)server.Capacity);
if (totalCapacity == 0 || requestedAccounts <= 0)
{
return (new BotServerPartition(1, 0, false), 0);
}
// What does not fit into the servers' share stays offline; those accounts wake up as soon as the
// deployment offers the room (another game server, a higher player limit or bot capacity share).
var assignedAccounts = (int)Math.Min(requestedAccounts, totalCapacity);
var partition = new BotServerPartition(1, 0, false);
long capacitySoFar = 0;
var accountsSoFar = 0;
foreach (var (currentServer, capacity) in servers)
{
capacitySoFar += capacity;
// Walk the cumulative capacity, so the rounding of one server's share is corrected by the
// next one instead of adding up: the shares always sum up to the assigned accounts exactly.
var accountsUpToHere = (int)(assignedAccounts * capacitySoFar / totalCapacity);
var share = accountsUpToHere - accountsSoFar;
if (currentServer == serverId && share > 0)
{
// The server which owns the first account generates the population.
partition = new BotServerPartition(accountsSoFar + 1, share, accountsSoFar == 0);
}
accountsSoFar = accountsUpToHere;
}
return (partition, assignedAccounts);
}
/// <summary>
/// Reads how many bot ACCOUNTS each configured game server may animate: its maximum player count,
/// reduced to the bots' share of it, divided by the characters an account animates at once. The
/// servers are ordered by their id, so every server walks the same list in the same order.
/// </summary>
private static async ValueTask<List<(byte ServerId, int Capacity)>> GetAccountCapacitiesAsync(
IGameContext gameContext,
BotConfiguration configuration,
int charactersPerAccount,
ILogger logger)
{
try
{
using var context = gameContext.PersistenceContextProvider.CreateNewConfigurationContext();
var definitions = await context.GetAsync<GameServerDefinition>().ConfigureAwait(false);
var capacityPercent = configuration.GetEffectiveBotCapacityPercent();
return definitions
.OrderBy(definition => definition.ServerID)
.Select(definition => (
definition.ServerID,
Capacity: (definition.ServerConfiguration?.MaximumPlayers ?? 0) * capacityPercent / 100 / charactersPerAccount))
.ToList();
}
catch (Exception ex)
{
logger.LogError(ex, "Could not read the game server definitions; this server animates the whole bot population.");
return [];
}
}
}

View File

@@ -0,0 +1,245 @@
// <copyright file="BotShoppingHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.GameLogic.PlayerActions;
using MUnique.OpenMU.GameLogic.PlayerActions.Items;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// Lets a bot trade with town merchants like a real player: when the backpack silts up or the potions
/// run low, the bot visits a merchant, sells its junk loot for Zen and buys potion refills with it -
/// closing the economic loop (materializing supplies out of thin air stays only as an emergency
/// fallback, see the low threshold in <see cref="BotNavigator"/>). The trade uses the regular player
/// actions (<see cref="TalkNpcAction"/>, <see cref="SellItemToNpcAction"/>, <see cref="BuyNpcItemAction"/>),
/// and while the dialog is open the player state pauses the combat AI - the bot visibly "shops".
/// </summary>
internal static class BotShoppingHandler
{
/// <summary>Start a shopping trip when fewer free backpack slots than this remain.</summary>
private const int FreeSlotPressure = 20;
/// <summary>Start a shopping trip when a potion kind has fewer charges than this.</summary>
private const int PotionLowThreshold = 40;
/// <summary>
/// Stop buying refills once this many charges are stocked. Merchants sell potions by the piece,
/// so this target keeps a trip affordable (roughly a bot's income between two trips) while lasting
/// a good while of hunting.
/// </summary>
private const int PotionTargetCharges = 60;
/// <summary>Maximum purchases per potion kind per trip.</summary>
private const int MaxPurchasesPerKind = 20;
/// <summary>Zen the bot keeps in reserve - it stops buying rather than spend its last coin.</summary>
private const int MinZenReserve = 10000;
private static readonly TalkNpcAction TalkAction = new();
private static readonly SellItemToNpcAction SellAction = new();
private static readonly BuyNpcItemAction BuyAction = new();
private static readonly CloseNpcDialogAction CloseAction = new();
private static readonly ItemIdentifier[] Valuables =
[
ItemConstants.JewelOfChaos,
ItemConstants.JewelOfBless,
ItemConstants.JewelOfSoul,
ItemConstants.JewelOfLife,
ItemConstants.JewelOfCreation,
ItemConstants.JewelOfGuardian,
ItemConstants.Gemstone,
ItemConstants.JewelOfHarmony,
ItemConstants.LowerRefineStone,
ItemConstants.HigherRefineStone,
];
/// <summary>
/// Determines whether the bot should go shopping: the backpack is filling up with sellable junk,
/// or a potion stack is running low (and there is Zen to restock with).
/// </summary>
/// <param name="player">The bot player.</param>
public static bool NeedsShopping(OfflinePlayer player)
{
if (player.Inventory is not { } inventory)
{
return false;
}
var freeSlots = inventory.FreeSlots.Count();
if (freeSlots < FreeSlotPressure && inventory.Items.Any(i => IsSellableJunk(player, i)))
{
return true;
}
if (player.Money > 5000 && GetLowPotionKinds(player).Any())
{
return true;
}
return false;
}
/// <summary>
/// Finds the position of a merchant NPC on the map, preferring one which sells potions. The
/// search runs over the map's LIVE objects, not the spawn configuration: wandering merchants
/// exist in the configuration but are only spawned now and then (their spawn trigger is not
/// automatic) - a bot walking to a configured but unspawned merchant would wait at an empty
/// spot and give up its trip, forever.
/// </summary>
/// <param name="map">The game map.</param>
public static Point? FindMerchantPosition(GameMap map)
{
// Covers the whole 256x256 map from its center.
var merchants = map.GetNpcsInRange(new Point(128, 128), 256)
.Where(n => n.Definition is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore.Items.Count: > 0 })
.ToList();
var best = merchants.FirstOrDefault(m => SellsPotions(m.Definition.MerchantStore!)) ?? merchants.FirstOrDefault();
return best?.Position;
}
/// <summary>
/// Performs the actual trade with the merchant standing near the given position: opens the dialog,
/// sells the junk loot, buys potion refills and closes the dialog again.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="map">The game map.</param>
/// <param name="merchantPosition">The position of the merchant.</param>
/// <returns>True, if a merchant was found and the trade ran; false if no merchant is there.</returns>
public static async ValueTask<bool> TryTradeAsync(OfflinePlayer player, GameMap map, Point merchantPosition)
{
var merchant = map.GetNpcsInRange(merchantPosition, 4)
.FirstOrDefault(n => n.Definition is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore.Items.Count: > 0 });
if (merchant is null || player.Inventory is not { } inventory)
{
// Not silent: exactly this case hid the wandering-merchant bug (a configured but
// unspawned merchant) for a long time.
player.Logger.LogInformation("Bot '{Name}' found no merchant near {Position} and gives up the trip.", player.Name, merchantPosition);
return false;
}
await TalkAction.TalkToNpcAsync(player, merchant).ConfigureAwait(false);
if (player.OpenedNpc is null)
{
player.Logger.LogInformation("Bot '{Name}' could not open the dialog of '{Merchant}'.", player.Name, merchant.Definition.Designation);
return false;
}
try
{
var soldCount = 0;
foreach (var junk in inventory.Items.Where(i => IsSellableJunk(player, i)).ToList())
{
await SellAction.SellItemAsync(player, junk.ItemSlot).ConfigureAwait(false);
soldCount++;
}
var boughtCount = 0;
var store = player.OpenedNpc.Definition.MerchantStore;
foreach (var potionNumber in GetLowPotionKinds(player))
{
var storeItem = store?.Items.FirstOrDefault(i => i.Definition?.Group == 14 && i.Definition.Number == potionNumber);
if (storeItem is null)
{
continue;
}
for (var i = 0; i < MaxPurchasesPerKind
&& GetPotionCharges(player, potionNumber) < PotionTargetCharges
&& player.Money > MinZenReserve; i++)
{
var moneyBefore = player.Money;
await BuyAction.BuyItemAsync(player, storeItem.ItemSlot).ConfigureAwait(false);
if (player.Money >= moneyBefore)
{
break; // purchase failed (no money / no space)
}
boughtCount++;
}
}
// Logged even for a 0/0 visit: an audit must be able to tell "went and had nothing to
// do" from a silently failed trip.
player.Logger.LogInformation(
"Bot '{Name}' traded with '{Merchant}': sold {Sold} item(s), bought {Bought} potion stack(s), {Money} zen left.",
player.Name,
merchant.Definition.Designation,
soldCount,
boughtCount,
player.Money);
}
finally
{
await CloseAction.CloseNpcDialogAsync(player).ConfigureAwait(false);
}
return true;
}
/// <summary>
/// Gets the potion kinds (item numbers in group 14) whose stack is running low.
/// </summary>
private static IEnumerable<byte> GetLowPotionKinds(Player player)
{
if (GetPotionCharges(player, 3) < PotionLowThreshold)
{
yield return 3; // Large Healing Potion
}
if (GetPotionCharges(player, 6) < PotionLowThreshold)
{
yield return 6; // Large Mana Potion
}
}
private static int GetPotionCharges(Player player, byte potionNumber)
{
return (int)(player.Inventory?.Items
.Where(i => i.Definition?.Group == 14 && i.Definition.Number == potionNumber)
.Sum(i => i.Durability) ?? 0);
}
/// <summary>
/// Sellable junk: unequipped gear in the backpack - not potions/ammunition and not jewels. An
/// excellent or ancient piece is junk too unless it is an upgrade the bot is about to wear:
/// unlike a player, a bot cannot trade its treasures away, so hoarding them only silts up the
/// backpack until the loot pickup stops entirely.
/// </summary>
private static bool IsSellableJunk(OfflinePlayer player, Item item)
{
if (item.ItemSlot < InventoryConstants.EquippableSlotsCount
|| item.Definition is not { } definition)
{
return false;
}
if (definition.Group >= 12 || definition.IsAmmunition)
{
return false; // potions, jewels, scrolls, event items etc.
}
if (Valuables.Contains(new ItemIdentifier(definition.Number, definition.Group)))
{
return false;
}
var isTreasure = item.ItemOptions.Any(o => o.ItemOption?.OptionType == DataModel.Configuration.Items.ItemOptionTypes.Excellent)
|| item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0);
if (isTreasure)
{
return !BotEquipmentHandler.IsUpgradeFor(player, item);
}
return true;
}
private static bool SellsPotions(DataModel.Entities.ItemStorage store)
{
return store.Items.Any(i => i.Definition?.Group == 14 && i.Definition.Number is 3 or 6);
}
}

View File

@@ -0,0 +1,177 @@
// <copyright file="BotSkillProgressionPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.PlayerActions.Character;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Grows a server-side bot like a real player when it levels up during play: the earned stat points are
/// invested according to the bot's class build (see <see cref="BotProgression.GetStatWeights"/>), and any
/// class skill whose learn requirements (total energy, leadership, character level, ...) are now met is
/// learned - attack skills as well as the class's own buffs and heals. Skills are only ever learned for
/// the character's own class (<see cref="Skill.QualifiedCharacters"/>), using the same requirements the
/// game enforces for human players, so a grown bot matches a freshly generated one of the same level.
/// </summary>
[PlugIn]
[Display(Name = "Bot skill progression", Description = "Invests level-up stat points and teaches server-side bots new class- and level-appropriate skills as they level up.")]
[Guid("D1F4A7C2-6B3E-4A59-8E71-9C0D2F5B6A84")]
public class BotSkillProgressionPlugIn : ICharacterLevelUpPlugIn
{
private static readonly IncreaseStatsAction IncreaseStatsAction = new();
private static readonly BotSkillProgressionPlugIn CatchUp = new();
/// <summary>
/// Applies the progression a bot is owed but has not spent, when it enters the world. Points are
/// otherwise only invested on a level-up, so a character which was given points while it was not
/// playing - a freshly generated one, or one whose level-up handler failed - would carry them around
/// unspent until its next level-up and fight with the strength of a much weaker character in the
/// meantime. Cheap: only a bot which actually holds points is progressed.
/// </summary>
/// <param name="player">The bot which entered the world.</param>
public static void CatchUpPendingProgress(Player player)
{
if (player.SelectedCharacter?.LevelUpPoints > 0)
{
CatchUp.CharacterLeveledUp(player);
}
}
/// <inheritdoc />
public void CharacterLeveledUp(Player player)
{
if (player.Account?.IsBot != true
|| player.SelectedCharacter?.CharacterClass is not { }
|| player.SkillList is not { })
{
return;
}
// Queue the progression into the bot's AI tick instead of running it right here: this hook fires
// from the experience/level-up path while the combat handler may be enumerating the skill list on
// its own timer - the tick serializes both. No own SaveChanges here - the new stats and skills
// are persisted by the periodic save, avoiding extra concurrency pressure.
if (player is Offline.OfflinePlayer offlinePlayer)
{
offlinePlayer.PendingBotActions.Enqueue(() => new ValueTask(this.ProgressAsync(offlinePlayer)));
}
else
{
_ = this.ProgressAsync(player);
}
}
private async Task ProgressAsync(Player player)
{
try
{
this.EvolveClassIfDue(player);
await this.SpendStatPointsAsync(player).ConfigureAwait(false);
await this.LearnNewSkillsAsync(player).ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogError(ex, "Failed to progress bot '{Name}' after level-up.", player.Name);
}
}
/// <summary>
/// Changes the bot into its second-generation class (Dark Knight -> Blade Knight etc.) once it
/// reaches <see cref="BotProgression.ClassEvolutionLevel"/> - the exact assignment the class-change
/// quest performs for a human player (see <c>QuestCompletionAction</c>); simulating the quest run
/// itself would be invisible to observers anyway. Skills and gear of the new class follow
/// automatically, because all bot progression keys off the current class's qualifications.
/// </summary>
private void EvolveClassIfDue(Player player)
{
var character = player.SelectedCharacter!;
if (player.Level < BotProgression.ClassEvolutionLevel
|| BotProgression.GetEvolutionTarget(character.CharacterClass!) is not { } evolvedClass)
{
return;
}
character.CharacterClass = evolvedClass;
player.Logger.LogInformation(
"Bot '{Name}' evolved into {Class} at level {Level}.",
player.Name,
evolvedClass.Name,
player.Level);
}
private async ValueTask SpendStatPointsAsync(Player player)
{
var character = player.SelectedCharacter!;
var characterClass = character.CharacterClass!;
var points = character.LevelUpPoints;
if (points <= 0)
{
return;
}
var resetMeta = BotResetHandler.GetResetConfiguration(player.GameContext) is not null;
var weights = BotProgression.GetStatWeights(characterClass, character.Name, resetMeta);
var vitalityTarget = resetMeta ? BotProgression.GetVitalityTarget(character.Name) : (int?)null;
// Mirrors the capacity checks of the stat-increase action (a stat's configured maximum on fun
// servers), plus the bot's personal vitality target on reset-meta servers: a full stat drops
// out of the split, so its share flows into the rest of the build instead of getting lost.
long CapacityOf(AttributeDefinition stat)
{
var classBase = characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == stat);
var current = (long)(player.Attributes?[stat] ?? 0f);
var capacity = long.MaxValue;
if (classBase?.Attribute?.MaximumValue is { } maximumValue)
{
capacity = (long)maximumValue - current;
}
if (vitalityTarget is { } target && stat == Stats.BaseVitality)
{
var invested = current - (long)(classBase?.BaseValue ?? 0f);
capacity = Math.Min(capacity, target - invested);
}
return capacity;
}
foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights, CapacityOf))
{
if (amount > 0)
{
await IncreaseStatsAction.IncreaseStatsAsync(player, stat, (ushort)amount).ConfigureAwait(false);
}
}
}
private async ValueTask LearnNewSkillsAsync(Player player)
{
var characterClass = player.SelectedCharacter!.CharacterClass!;
var skillList = player.SkillList!;
float? GetValue(AttributeDefinition attribute) => player.Attributes?[attribute];
foreach (var skill in player.GameContext.Configuration.Skills)
{
if (!BotProgression.IsBotLearnableSkill(skill)
|| !skill.QualifiedCharacters.Contains(characterClass)
|| skillList.ContainsSkill((ushort)skill.Number)
|| !BotProgression.MeetsRequirements(skill, GetValue))
{
continue;
}
await skillList.AddLearnedSkillAsync(skill).ConfigureAwait(false);
player.Logger.LogInformation("Bot '{Name}' learned '{Skill}' at level {Level}.", player.Name, skill.Name, player.Level);
}
}
}

View File

@@ -0,0 +1,263 @@
// <copyright file="BotWingHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Offline;
/// <summary>
/// Grants a bot its wings at the classic level milestones, like a player who saves up for them:
/// at level <see cref="FirstTierLevel"/> the first pair (+0, luck, +12 option), at
/// <see cref="SecondTierLevel"/> the second pair (+9, luck, +16 option) and at
/// <see cref="ThirdTierLevel"/> the third pair (+15, luck, +16 option). Wings don't drop from
/// monsters, so they are created directly and put straight into the wing slot - the planner
/// guarantees the class qualification and level requirement a regular equip would check, and the
/// slot placement mounts the power-ups like one. The outgrown pair is destroyed - never dropped,
/// so no player can grab a made-up wing from the ground.
/// The wing model of the item data does the rest: which classes may wear which pair is defined by
/// <see cref="ItemDefinition.QualifiedCharacters"/>, so e.g. the third-tier wings (master classes
/// only) are simply not offered to a bot which did not evolve yet, and the Dark Lord/Rage Fighter
/// capes - their only pre-master wing - are re-granted as a fresh +9 cape at the second milestone.
/// </summary>
internal static class BotWingHandler
{
/// <summary>The level at which a bot gets its first tier wings (+0, luck, +12 option).</summary>
private const int FirstTierLevel = 180;
/// <summary>The level at which a bot gets its second tier wings (+9, luck, +16 option).</summary>
private const int SecondTierLevel = 280;
/// <summary>The level at which a bot gets its third tier wings (+15, luck, +16 option).</summary>
private const int ThirdTierLevel = 400;
/// <summary>The item level of the second tier grant; also disambiguates the tier of a cape (see <see cref="TierOf"/>).</summary>
private const byte SecondTierItemLevel = 9;
/// <summary>First tier wing numbers (group 12): Wings of Elf, Heaven, Satan and Curse.</summary>
private static readonly (byte Group, short Number)[] FirstTierIds = { (12, 0), (12, 1), (12, 2), (12, 41) };
/// <summary>Second tier wing numbers (group 12): Wings of Spirits, Soul, Dragon, Darkness and Despair.</summary>
private static readonly (byte Group, short Number)[] SecondTierIds = { (12, 3), (12, 4), (12, 5), (12, 6), (12, 42) };
/// <summary>Third tier wing numbers (group 12): Wing of Storm, Eternal, Illusion, Ruin, Dimension and the Capes of Emperor/Overrule.</summary>
private static readonly (byte Group, short Number)[] ThirdTierIds = { (12, 36), (12, 37), (12, 38), (12, 39), (12, 40), (12, 43), (12, 50) };
/// <summary>
/// The Cape of Lord (13, 30, Dark Lord) and Cape of Fighter (12, 49, Rage Fighter): the only
/// pre-master wing of their classes, granted at the first milestone at +0 and again at the
/// second as a fresh +9 cape. Unlike all other wings, the Cape of Lord lives in group 13 -
/// group 12 number 30 is the Packed Jewel of Bless (which the wing-slot filter of
/// <see cref="PlanNextGrant"/> would keep out of the candidates anyway).
/// </summary>
private static readonly (byte Group, short Number)[] CapeIds = { (13, 30), (12, 49) };
/// <summary>
/// Checks the bot's level milestones and puts on the earned wings; called from the bot's regular
/// evaluation cadence, queued into the MuHelper tick because equipping mounts item power-ups.
/// </summary>
/// <param name="player">The bot player.</param>
public static async ValueTask TryAdvanceWingsAsync(OfflinePlayer player)
{
if (PlanNextGrant(player) is not { } plan || player.Inventory is not { } inventory)
{
return;
}
// The outgrown pair is destroyed, not dropped - a conjured wing must never lie on the
// ground for a player to pick up. Clearing the slot first also keeps the grant independent
// of the backpack, which is usually too crammed with loot for a wing's 5x3 footprint.
if (inventory.GetItem(InventoryConstants.WingsSlot) is { } outgrown)
{
await player.DestroyInventoryItemAsync(outgrown).ConfigureAwait(false);
player.Logger.LogInformation("Bot '{Name}' discarded its outgrown wings '{Wings}'.", player.Name, outgrown);
}
// Directly into the wing slot: the planner already guarantees the class qualification and
// the level requirement, and the slot placement mounts the power-ups and broadcasts the
// changed appearance like a regular equip.
var wings = CreateWings(player, plan);
if (!await inventory.AddItemAsync(InventoryConstants.WingsSlot, wings).ConfigureAwait(false))
{
// Shouldn't happen - the slot was just cleared; don't leak the created item.
await player.PersistenceContext.DeleteAsync(wings).ConfigureAwait(false);
player.Logger.LogWarning("Bot '{Name}' could not equip its new wings '{Wings}'.", player.Name, wings);
return;
}
player.Logger.LogInformation("Bot '{Name}' earned its tier {Tier} wings: '{Wings}'.", player.Name, plan.Tier, wings);
try
{
// Persist right away like after using jewels - a milestone shouldn't be lost (and the
// outgrown pair resurrected) by a crash before the next periodic save.
await player.SaveProgressAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after granting wings; the periodic save will retry.", player.Name);
}
}
/// <summary>
/// Determines the wings the bot has earned but does not wear yet, or <c>null</c> when it already
/// wears its best earned pair (or none is due). Pure decision logic - exposed for unit tests.
/// </summary>
/// <param name="player">The bot player.</param>
internal static (ItemDefinition Definition, byte ItemLevel, int OptionLevel, int Tier)? PlanNextGrant(Player player)
{
if (player.Inventory is not { } inventory
|| player.SelectedCharacter?.CharacterClass is not { } characterClass
|| player.Attributes is not { } attributes)
{
return null;
}
var level = (int)attributes[Stats.Level];
var earnedTier = level switch
{
>= ThirdTierLevel => 3,
>= SecondTierLevel => 2,
>= FirstTierLevel => 1,
_ => 0,
};
// Walk down from the earned tier to the best one the class currently qualifies for: a bot
// which did not evolve into its master class yet simply isn't qualified for the third tier
// wings and keeps its second pair until the evolution.
for (var tier = earnedTier; tier >= 1; tier--)
{
var candidates = player.GameContext.Configuration.Items
.Where(d => TierIds(tier).Contains((d.Group, d.Number))
&& d.ItemSlot?.ItemSlots.Contains(InventoryConstants.WingsSlot) == true
&& d.QualifiedCharacters.Contains(characterClass))
.ToList();
if (candidates.Count == 0)
{
continue;
}
if (inventory.GetItem(InventoryConstants.WingsSlot) is { } equipped && TierOf(equipped) >= tier)
{
// Already wearing this tier (or a better one, e.g. right after a reset while
// re-levelling through the lower milestones) - never downgrade.
return null;
}
var isCaster = attributes[Stats.TotalEnergy] > attributes[Stats.TotalStrength];
var definition = candidates.MaxBy(d => FindWingOption(d, isCaster).Score)!;
var (itemLevel, optionLevel) = tier switch
{
3 => ((byte)15, 4),
2 => (SecondTierItemLevel, 4),
_ => ((byte)0, 3),
};
return (definition, itemLevel, optionLevel, tier);
}
return null;
}
private static IReadOnlyCollection<(byte Group, short Number)> TierIds(int tier)
{
return tier switch
{
3 => ThirdTierIds,
2 => SecondTierIds.Concat(CapeIds).ToList(),
_ => FirstTierIds.Concat(CapeIds).ToList(),
};
}
/// <summary>
/// The tier a worn wing counts as; the capes are the first-tier grant of their classes but count
/// as the second one once re-granted at +9.
/// </summary>
private static int TierOf(Item wings)
{
if (wings.Definition is not { } definition)
{
return 0;
}
var id = (definition.Group, definition.Number);
if (ThirdTierIds.Contains(id))
{
return 3;
}
if (CapeIds.Contains(id))
{
return wings.Level >= SecondTierItemLevel ? 2 : 1;
}
if (SecondTierIds.Contains(id))
{
return 2;
}
return FirstTierIds.Contains(id) ? 1 : 0;
}
/// <summary>
/// Picks the wing's "additional" option (the +4-per-level one, <see cref="ItemOptionTypes.Option"/>)
/// which fits the bot's fighting style best - wizardry damage for casters, physical damage
/// otherwise - and scores it, so wings offering the matching damage option (the Magic Gladiator
/// may wear both Wings of Heaven and Satan) win the candidate selection.
/// </summary>
private static (IncreasableItemOption? Option, int Score) FindWingOption(ItemDefinition definition, bool isCaster)
{
static int ScoreOf(IncreasableItemOption option, bool isCaster)
{
var target = option.PowerUpDefinition?.TargetAttribute;
if (target == Stats.WizardryBaseDmg || target == Stats.CurseBaseDmg)
{
return isCaster ? 3 : 1;
}
if (target == Stats.PhysicalBaseDmg)
{
return isCaster ? 1 : 3;
}
return 0;
}
return definition.PossibleItemOptions
.SelectMany(o => o.PossibleOptions)
.Where(o => o.OptionType == ItemOptionTypes.Option)
.Select(o => ((IncreasableItemOption?)o, ScoreOf(o, isCaster)))
.OrderByDescending(pair => pair.Item2)
.FirstOrDefault();
}
private static Item CreateWings(OfflinePlayer player, (ItemDefinition Definition, byte ItemLevel, int OptionLevel, int Tier) plan)
{
var item = player.PersistenceContext.CreateNew<Item>();
item.Definition = plan.Definition;
item.Level = plan.ItemLevel;
item.Durability = plan.Definition.Durability;
if (plan.Definition.PossibleItemOptions
.SelectMany(o => o.PossibleOptions)
.FirstOrDefault(o => o.OptionType == ItemOptionTypes.Luck) is { } luck)
{
var luckLink = player.PersistenceContext.CreateNew<ItemOptionLink>();
luckLink.ItemOption = luck;
item.ItemOptions.Add(luckLink);
}
var isCaster = player.Attributes![Stats.TotalEnergy] > player.Attributes[Stats.TotalStrength];
if (FindWingOption(plan.Definition, isCaster).Option is { } option)
{
var optionLink = player.PersistenceContext.CreateNew<ItemOptionLink>();
optionLink.ItemOption = option;
optionLink.Level = plan.OptionLevel;
item.ItemOptions.Add(optionLink);
}
return item;
}
}

View File

@@ -0,0 +1,13 @@
// <copyright file="PendingPartyInvite.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
/// <summary>
/// A party invitation from a player which a bot accepted, waiting for the human-like delay to pass
/// before the party is actually formed (see <see cref="BotPartyHandler"/>).
/// </summary>
/// <param name="Requester">The player who invited the bot.</param>
/// <param name="AcceptAtUtc">When the bot answers the invitation.</param>
internal sealed record PendingPartyInvite(Player Requester, DateTime AcceptAtUtc);

View File

@@ -131,6 +131,17 @@ public class GameMap
return this._areaOfInterestManager.GetInRange(point, range).OfType<IAttackable>().ToList();
}
/// <summary>
/// Gets all non-player characters (e.g. merchants) within the specified range of a point.
/// </summary>
/// <param name="point">The coordinates.</param>
/// <param name="range">The range.</param>
/// <returns>The non-player characters in range of the specified coordinate.</returns>
public IList<NonPlayerCharacter> GetNpcsInRange(Point point, int range)
{
return this._areaOfInterestManager.GetInRange(point, range).OfType<NonPlayerCharacter>().ToList();
}
/// <summary>
/// Gets all dropped items and money within the specified range of a point.
/// </summary>

View File

@@ -137,6 +137,37 @@ public static class ItemExtensions
return item.Definition?.Group == ShieldItemGroup;
}
/// <summary>
/// Determines whether equipping an item of this definition into the given hand slot would conflict
/// with what the other hand already holds: a two-handed item needs the other hand free (ammunition
/// aside), and nothing but ammunition fits next to an already equipped two-handed item.
/// </summary>
/// <param name="itemDefinition">The definition of the item which would be equipped.</param>
/// <param name="inventory">The inventory to check the other hand in.</param>
/// <param name="toSlot">The hand slot the item would be equipped into.</param>
/// <returns><c>true</c> if the other hand blocks this item; otherwise, <c>false</c>.</returns>
public static bool ConflictsWithEquippedHands(this ItemDefinition itemDefinition, IStorage inventory, byte toSlot)
{
if (itemDefinition.ItemSlot is null)
{
return false;
}
static bool IsOneHandedOrShield(ItemDefinition definition) =>
(definition.ItemSlot!.ItemSlots.Contains(InventoryConstants.RightHandSlot) && definition.ItemSlot.ItemSlots.Contains(InventoryConstants.LeftHandSlot))
|| definition.Group == ShieldItemGroup;
var rightHandItemDefinition = inventory.GetItem(InventoryConstants.RightHandSlot)?.Definition;
return (toSlot == InventoryConstants.LeftHandSlot
&& itemDefinition.Width >= 2
&& rightHandItemDefinition is not null
&& !rightHandItemDefinition.IsAmmunition)
|| (toSlot == InventoryConstants.RightHandSlot
&& IsOneHandedOrShield(itemDefinition)
&& inventory.GetItem(InventoryConstants.LeftHandSlot)?.Definition?.Width >= 2);
}
/// <summary>
/// Determines whether this item is a jewelry (pendant or ring) item.
/// </summary>

View File

@@ -150,6 +150,50 @@ public interface IMuHelperSettings
/// <summary>Gets a value indicating whether to automatically accept requests from guild.</summary>
bool AutoAcceptGuild { get; }
/// <summary>
/// Gets a value indicating whether to automatically accept party requests from anyone, not just
/// friends or guild mates. Defaults to <c>false</c>; used by server-side bots so they group up
/// with players who invite them (see <c>Bots.BotPartyHandler</c> for the applied safeguards).
/// </summary>
bool AutoAcceptAnyone => false;
/// <summary>Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used.</summary>
bool FallbackBasicAttack { get; }
/// <summary>
/// Gets a value indicating whether the combat AI should automatically cast the strongest learned
/// attack skill the character can currently afford, instead of relying on the explicitly configured
/// skill IDs. Used by server-side bots (which have no client-side MU Helper config) so they fight
/// with class- and level-appropriate skills; human offline sessions keep their explicit configuration.
/// </summary>
bool AutoSelectBestSkill { get; }
/// <summary>
/// Gets a value indicating whether the buff AI should automatically cast the learned buff skills
/// of the character, instead of relying on the explicitly configured buff slot IDs. Used by
/// server-side bots so each class keeps its own buffs up (e.g. elf Greater Defense/Greater Damage);
/// human offline sessions keep their explicit configuration.
/// </summary>
bool AutoSelectBuffs { get; }
/// <summary>
/// Gets a value indicating whether to drink a mana potion when mana runs low, so casters can keep
/// casting. There is no client-side MU Helper setting for this; it is used by server-side bots.
/// </summary>
bool UseManaPotion { get; }
/// <summary>
/// Gets a value indicating whether the combat AI only engages monsters the character can safely
/// handle (up to half its own level, like the bot navigator's hunting-ground selection). Without
/// this, a bot travelling through hostile territory would pick fights with monsters far above its
/// level and die. Human offline sessions keep the unrestricted behavior - the player chose the spot.
/// </summary>
bool OnlyHuntSafeMonsters { get; }
/// <summary>
/// Gets a value indicating whether to also pick up equippable items which are an upgrade over the
/// character's currently equipped gear (evaluated before pickup), so bots progress their equipment
/// like a real player without hoarding junk.
/// </summary>
bool PickUpgradeItems { get; }
}

View File

@@ -38,6 +38,13 @@ public static class PartyRequestHandler
return true;
}
if (settings.AutoAcceptAnyone && await Bots.BotPartyHandler.TryScheduleAcceptAsync(receiver, requester).ConfigureAwait(false))
{
// The actual accept happens shortly afterwards in the bot's own tick (a human-like delay);
// all bot-specific safeguards live in the handler, so this stays a thin criteria branch.
return true;
}
return false;
}

View File

@@ -25,6 +25,8 @@ public sealed class BuffHandler
private int _nextSlotIndex;
private bool _buffTimerTriggered;
private DateTime? _nextPeriodicBuffTime;
private IList<int>? _cachedAutoBuffIds;
private int _cachedAutoBuffSkillCount = -1;
/// <summary>
/// Initializes a new instance of the <see cref="BuffHandler"/> class.
@@ -38,7 +40,9 @@ public sealed class BuffHandler
}
/// <summary>
/// Gets the configured buff skill IDs from the settings.
/// Gets the configured buff skill IDs from the settings. With <see cref="IMuHelperSettings.AutoSelectBuffs"/>
/// enabled (server-side bots), the character's learned buff skills are used instead of the explicitly
/// configured slots, so each class keeps its own buffs up without any per-character configuration.
/// </summary>
public IList<int> ConfiguredBuffIds
{
@@ -49,6 +53,34 @@ public sealed class BuffHandler
return [];
}
if (this._config.AutoSelectBuffs && this._player.SkillList is { } skillList)
{
// The learned buffs only change when a new skill is learned, so the list is cached and
// only rebuilt when the skill count changes - building it fresh with LINQ on every
// 500ms tick of hundreds of bots was measurable CPU for no benefit.
var skillCount = skillList.Skills.Count();
if (this._cachedAutoBuffIds is null || skillCount != this._cachedAutoBuffSkillCount)
{
var learnedBuffs = skillList.Skills
.Where(s => s.Skill is { SkillType: SkillType.Buff, MagicEffectDef: not null })
.Select(s => (int)s.Skill!.Number)
.OrderBy(n => n)
.Take(BuffSlotCount)
.ToList();
// Pad to the fixed slot count - the caller indexes all three slots; 0 means "slot empty".
while (learnedBuffs.Count < BuffSlotCount)
{
learnedBuffs.Add(0);
}
this._cachedAutoBuffIds = learnedBuffs;
this._cachedAutoBuffSkillCount = skillCount;
}
return this._cachedAutoBuffIds;
}
return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id];
}
}
@@ -246,8 +278,21 @@ public sealed class BuffHandler
return false;
}
return target.MagicEffectList.ActiveEffects.Values
.Any(e => e.Definition == effectDef);
try
{
// Eager snapshot, like the other readers of ActiveEffects (see MagicEffectsList): the
// list is mutated by the effect expiry timers, and a lazy enumeration from this
// (unsynchronized) helper tick raced them regularly at scale. A list shrinking in the
// middle of the copy can still leave null holes in the snapshot (hence the tolerant
// predicate) or throw out of the copy itself (hence the catch-all around this pure
// read) - any torn read simply counts as "active", and the next tick retries.
var activeEffects = target.MagicEffectList.ActiveEffects.Values.ToArray();
return activeEffects.Any(e => e?.Definition == effectDef);
}
catch (Exception)
{
return true;
}
}
private void UpdatePeriodicBuffTimer()

View File

@@ -4,7 +4,10 @@
namespace MUnique.OpenMU.GameLogic.Offline;
using System.Collections.Concurrent;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Bots;
using MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
@@ -19,26 +22,82 @@ public sealed class CombatHandler
{
private const byte DefaultRange = 1;
private const byte BowRange = 6;
/// <summary>
/// See <see cref="IsSafeTarget"/>: the largest share of the bot's maximum health a single average
/// monster hit may take for the monster to count as safe. Sized so the bot survives several hits
/// even when a few monsters aggro at once, with the healing handler (potions at 60%) keeping up.
/// Tightened from 0.20 with the player-meta stat builds: their small health pools mean melee bots
/// (which stand inside the monster pack) need a bigger margin per hit to survive a swarm.
/// </summary>
private const float SafeHitHealthShare = 0.15f;
/// <summary>
/// See <see cref="IsSafeTarget"/>: the bot's attack power must exceed the monster's defense by this
/// factor. Without it a bot picks fights it can barely scratch - e.g. the Vulcanus tank monsters
/// (defense ~340, health ~100k) shrug off a modestly geared bot's hits, the "fight" lasts minutes,
/// and the accumulated damage kills the bot even though every single hit it takes looks survivable.
/// </summary>
private const float MinAttackAdvantage = 1.2f;
/// <summary>
/// See <see cref="IsSafeTarget"/>: the monster must die within this many net hits of the bot.
/// The per-hit checks alone let a fighter "safely" besiege a 100k-health tank monster for ten
/// minutes, until its potions ran dry and it died anyway - the fight length itself is the risk.
/// At the offline AI's attack pace this bounds a kill to roughly a minute or two.
/// </summary>
private const int MaxHitsToKill = 100;
/// <summary>
/// See <see cref="IsSafeTarget"/>: how much longer a mastered bot may take to kill a monster which
/// pays master experience. Master experience is only granted for monsters of at least
/// <c>GameConfiguration.MinimumMonsterLevelForMasterExperience</c>, and those hold 40.000+ health -
/// out of reach of the regular hit budget for a bot in the gear it collects from drops, which left
/// mastered bots hunting monsters that pay them nothing at all. Since a character at the maximum
/// level earns nothing else either, a long fight it survives beats a quick one worth zero: the
/// budget is stretched for those monsters only, while the survivability check below is NOT - a bot
/// still refuses a monster whose hits it cannot take.
/// </summary>
private const int MasterHitBudgetFactor = 3;
private const int ComboFinisherDelayTicks = 3;
private const int InterSkillDelayTicks = 1;
private const int MinComboSkillCount = 3;
/// <summary>After this many consecutive failed approaches the target counts as unreachable.</summary>
private const int MaxApproachFailures = 3;
private const short DrainLifeBaseSkillId = 214;
private const short DrainLifeStrengthenerSkillId = 458;
private const short DrainLifeMasterySkillId = 462;
/// <summary>How long an unreachable target is ignored before it may be considered again.</summary>
private static readonly TimeSpan UnreachableTargetBlacklistDuration = TimeSpan.FromSeconds(10);
private static readonly TargetedSkillDefaultPlugin DefaultPlugin = new();
/// <summary>
/// Cache of the combat-relevant stats of monster definitions (config data, immutable at runtime),
/// so the safety checks of hundreds of bots don't re-scan the attribute lists every tick. Keyed by
/// the monster number rather than the definition instance, so a configuration reload (which builds
/// new <see cref="MonsterDefinition"/> instances) reuses the entries instead of orphaning them.
/// </summary>
private static readonly ConcurrentDictionary<short, (int Level, float AverageDamage, float Defense, float Health, float AttackRate)> MonsterStatsCache = new();
private readonly OfflinePlayer _player;
private readonly IMuHelperSettings? _config;
private readonly MovementHandler _movementHandler;
private readonly Point _originPosition;
private readonly ConditionalSkillSlot[] _conditionalSkillSlots;
private IAttackable? _currentTarget;
private int _nearbyMonsterCount;
private int _currentComboStep;
private int _skillCooldownTicks;
private int _approachFailures;
private ushort _unreachableTargetId;
private DateTime _unreachableTargetUntilUtc = DateTime.MinValue;
private SkillEntry? _tickBestSkill;
private bool _tickBestSkillComputed;
private DateTime _engageAtUtc = DateTime.MinValue;
/// <summary>
/// Initializes a new instance of the <see cref="CombatHandler"/> class.
@@ -46,13 +105,11 @@ public sealed class CombatHandler
/// <param name="player">The offline player.</param>
/// <param name="config">The MU helper settings.</param>
/// <param name="movementHandler">The movement handler.</param>
/// <param name="originPosition">The original position to hunt around.</param>
public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHandler movementHandler, Point originPosition)
public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHandler movementHandler)
{
this._player = player;
this._config = config;
this._movementHandler = movementHandler;
this._originPosition = originPosition;
this._conditionalSkillSlots = config is null ? [] :
[
new ConditionalSkillSlot(config.ActivationSkill1Id, config.Skill1UseTimer, config.DelayMinSkill1, config.Skill1UseCondition, config.Skill1ConditionAttacking, config.Skill1SubCondition),
@@ -70,6 +127,19 @@ public sealed class CombatHandler
/// </summary>
public byte HuntingRange => CalculateHuntingRange(this._config);
/// <summary>
/// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds.
/// </summary>
private Point OriginPosition => this._player.HuntingOrigin;
/// <summary>
/// Gets a value indicating whether this session animates a server-side bot rather than the offline
/// session of a real player. Bots trade a bit of hunting efficiency for looking human (reaction
/// delay, target spread); a player's offline session must behave exactly as it did before the bots
/// moved into this handler.
/// </summary>
private bool IsBot => this._player.Account?.IsBot == true;
/// <summary>
/// Calculates the hunting range in tiles from the specified configuration.
/// </summary>
@@ -85,6 +155,72 @@ public sealed class CombatHandler
return (byte)Math.Max(DefaultRange, config.HuntingRange);
}
/// <summary>
/// Determines whether the monster is one the bot can fight without dying, judged by the monster's
/// REAL combat stats instead of its nominal level: the average hit it lands (its base damage minus
/// the bot's PvM defense, the same subtraction the damage formula applies) must not exceed
/// <see cref="SafeHitHealthShare"/> of the bot's maximum health. A monster's level says nothing
/// about its punch - the high-end maps (Swamp of Calmness, LaCleon, the event fortresses) field
/// "level ~120" monsters which hit for 1000-2300 base damage, several times what regular maps'
/// monsters of the same level deal - so a level cap sent high-level bots in modest gear straight
/// into a death loop there. Judging by damage also scales naturally with equipment: better armor
/// raises the bot's defense and unlocks tougher maps, exactly like it does for a real player.
/// The bot's own offense must in turn exceed the monster's defense (<see cref="MinAttackAdvantage"/>),
/// so it never besieges a tank monster it can barely scratch, and the monster's level must not
/// exceed the bot's own (on reset servers: its reset-aware effective level, see
/// <see cref="BotResetHandler.GetEffectiveLevel"/>).
/// Shared by the combat AI and the bot navigator, so a bot never stops travelling for (or engages)
/// a monster it should not fight.
/// </summary>
/// <param name="player">The bot player.</param>
/// <param name="monster">The monster definition.</param>
public static bool IsSafeTarget(Player player, MonsterDefinition monster)
{
var (monsterLevel, averageDamage, monsterDefense, monsterHealth, monsterAttackRate) = GetMonsterCombatStats(monster);
if (monsterLevel <= 0 || player.Attributes is not { } attributes)
{
return false;
}
// Reset-aware: on servers with the reset feature a freshly reset character is nominally a
// low level again but keeps the strength of its resets - the effective level keeps it from
// being locked out of the maps it just hunted on.
if (monsterLevel > BotResetHandler.GetEffectiveLevel(player))
{
return false;
}
var netHit = Math.Max(0f, averageDamage - attributes[Stats.DefensePvm]) * GetExpectedHitShare(player, monsterAttackRate);
if (netHit > SafeHitHealthShare * Math.Max(1f, attributes[Stats.MaximumHealth]))
{
return false;
}
var attackPower = GetAttackPower(player);
if (attackPower <= monsterDefense * MinAttackAdvantage)
{
return false;
}
return (attackPower - monsterDefense) * GetHitBudget(player, monsterLevel) >= monsterHealth;
}
/// <summary>
/// The number of net hits the bot may take to kill the monster (see <see cref="MaxHitsToKill"/>),
/// stretched by <see cref="MasterHitBudgetFactor"/> for a mastered bot fighting a monster which
/// actually pays it master experience (see <see cref="MasterHitBudgetFactor"/>).
/// </summary>
private static int GetHitBudget(Player player, int monsterLevel)
{
var configuration = player.GameContext.Configuration;
var isMastered = player.SelectedCharacter?.CharacterClass?.IsMasterClass == true
&& (player.Attributes?[Stats.Level] ?? 0) >= configuration.MaximumLevel;
return isMastered && monsterLevel >= configuration.MinimumMonsterLevelForMasterExperience
? MaxHitsToKill * MasterHitBudgetFactor
: MaxHitsToKill;
}
/// <summary>
/// Decrements the skill cooldown counter by one tick.
/// </summary>
@@ -101,6 +237,7 @@ public sealed class CombatHandler
/// </summary>
public async ValueTask PerformAttackAsync()
{
var previousTarget = this._currentTarget;
this.RefreshTarget();
if (this._currentTarget is null)
@@ -108,13 +245,47 @@ public sealed class CombatHandler
return;
}
// A human doesn't strike the very same instant a new target appears: give each fresh target a
// small randomized reaction delay (the bot faces it, then engages) - the perfectly metronomic
// instant-strike cadence is one of the clearest bot giveaways. Only bots pay for the disguise:
// a player's own offline session must hunt exactly as fast as it always did.
if (this.IsBot)
{
if (!ReferenceEquals(previousTarget, this._currentTarget))
{
this._engageAtUtc = DateTime.UtcNow.AddMilliseconds(Rand.NextInt(250, 900));
}
if (DateTime.UtcNow < this._engageAtUtc)
{
this._player.Rotation = this._player.GetDirectionTo(this._currentTarget);
return;
}
}
byte attackRange = this.GetEffectiveAttackRange();
if (!this.IsTargetInAttackRange(this._currentTarget, attackRange))
{
await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false);
if (await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false))
{
this._approachFailures = 0;
}
else if (++this._approachFailures >= MaxApproachFailures)
{
// The target is in (Euclidean) range but no walkable path leads to it - e.g. a monster
// across a wall or river. Blacklist it briefly and drop it, so the bot picks another
// target (or moves on) instead of standing in front of the obstacle forever.
this._unreachableTargetId = this._currentTarget.Id;
this._unreachableTargetUntilUtc = DateTime.UtcNow + UnreachableTargetBlacklistDuration;
this._currentTarget = null;
this._approachFailures = 0;
}
return;
}
this._approachFailures = 0;
if (this._config?.UseCombo == true)
{
await this.ExecuteComboAttackAsync().ConfigureAwait(false);
@@ -157,6 +328,75 @@ public sealed class CombatHandler
}
}
private static (int Level, float AverageDamage, float Defense, float Health, float AttackRate) GetMonsterCombatStats(MonsterDefinition monster)
{
return MonsterStatsCache.GetOrAdd(
monster.Number,
static (_, m) =>
{
float GetValue(AttributeDefinition attribute)
=> m.Attributes.FirstOrDefault(a => a.AttributeDefinition == attribute)?.Value ?? 0f;
var level = (int)GetValue(Stats.Level);
var averageDamage = (GetValue(Stats.MinimumPhysBaseDmg) + GetValue(Stats.MaximumPhysBaseDmg)) / 2f;
return (level, averageDamage, GetValue(Stats.DefenseBase), GetValue(Stats.MaximumHealth), GetValue(Stats.AttackRatePvm));
},
monster);
}
/// <summary>
/// How much of the monster's average hit actually lands on the bot, over time: the engine rolls
/// every monster swing against the bot's defense rate (see the hit chance in
/// <see cref="AttackableExtensions"/>), so an agility-based character tanks by DODGING, not by
/// soaking. Judging its safety by the raw hit alone declared every such build too squishy for
/// anything past the starter maps - and left the whole caster population stuck on Lorencia.
/// The dodge credit is capped (a bot must not bet its life on a lucky evade streak).
/// </summary>
private static float GetExpectedHitShare(Player player, float monsterAttackRate)
{
const float minimumAssumedHitChance = 0.25f;
if (monsterAttackRate <= 0f || player.Attributes is not { } attributes)
{
return 1f;
}
var defenseRate = attributes[Stats.DefenseRatePvm];
var hitChance = defenseRate < monsterAttackRate ? 1f - (defenseRate / monsterAttackRate) : 0.03f;
return Math.Clamp(hitChance, minimumAssumedHitChance, 1f);
}
/// <summary>
/// A rough estimate of the bot's punch: its best base damage kind (physical for fighters, wizardry
/// for casters, curse for summoners) plus the strongest attack skill it has learned - enough to tell
/// apart "kills this monster at a reasonable pace" from "barely scratches it".
/// </summary>
private static float GetAttackPower(Player player)
{
if (player.Attributes is not { } attributes)
{
return 0f;
}
var physical = (attributes[Stats.MinimumPhysBaseDmg] + attributes[Stats.MaximumPhysBaseDmg]) / 2f;
// The Min/Max wizardry damage is what a caster actually hits with (energy feeds it, see the
// class attribute relations); Stats.WizardryBaseDmg is only the bonus channel of the staff's
// rise - reading it made every caster look like it had no offense at all, so it never passed
// the checks below for anything but the starter maps and stayed there forever.
var wizardry = (attributes[Stats.MinimumWizBaseDmg] + attributes[Stats.MaximumWizBaseDmg]) / 2f;
var curse = (attributes[Stats.MinimumCurseBaseDmg] + attributes[Stats.MaximumCurseBaseDmg]) / 2f;
var skillDamage = 0;
foreach (var entry in player.SkillList?.Skills ?? [])
{
if (entry.Skill is { AttackDamage: > 0 } skill && skill.AttackDamage > skillDamage)
{
skillDamage = skill.AttackDamage;
}
}
return Math.Max(physical, Math.Max(wizardry, curse)) + skillDamage;
}
private async ValueTask ExecuteAttackAsync(IAttackable target)
{
var skill = this.SelectAttackSkill();
@@ -170,6 +410,13 @@ public sealed class CombatHandler
private async ValueTask ExecuteAttackAsync(IAttackable target, SkillEntry? skillEntry, bool isCombo)
{
// Last line of defense for the "bot must never become an outlaw" invariant: no strike ever
// leaves this handler against a player who isn't a legal PvP target right now.
if (target is Player playerTarget && !BotPvpRules.IsLegalPvpTarget(this._player, playerTarget))
{
return;
}
this._player.Rotation = this._player.GetDirectionTo(target);
if (skillEntry?.Skill is not { } skill)
@@ -190,6 +437,26 @@ public sealed class CombatHandler
private void RefreshTarget()
{
// The best-skill choice is cached for the duration of one tick (it is needed for both the range
// check and the actual attack); a new tick starts with a fresh choice.
this._tickBestSkill = null;
this._tickBestSkillComputed = false;
// Self-defense has priority over farming: a player who recently attacked this bot becomes the
// target, as long as they are still viable and anywhere near. Without this the bot placidly
// keeps hitting monsters while a player kills it. The aggressor memory only sets the PRIORITY,
// though - whether the bot may actually strike is decided by BotPvpRules per attack: the grudge
// outlives the game's self-defense window, and striking outside of it would turn the bot into
// an outlaw (see BotPvpRules.IsLegalPvpTarget).
if (this._config?.UseSelfDefense == true
&& this._player.RecentAggressor is { } aggressor
&& aggressor.IsInRange(this._player.Position, this.HuntingRange * 2)
&& BotPvpRules.IsLegalPvpTarget(this._player, aggressor))
{
this._currentTarget = aggressor;
return;
}
if (this._currentTarget is { } t && !this.IsTargetStillValid(t))
{
this._currentTarget = null;
@@ -197,13 +464,24 @@ public sealed class CombatHandler
if (this._currentTarget is null)
{
var monsters = this.GetAttackableMonstersInHuntingRange().ToList();
this._currentTarget = monsters.MinBy(m => m.GetDistanceTo(this._player));
this._nearbyMonsterCount = monsters.Count;
var targets = this.GetAttackableTargetsInHuntingRange().ToList();
var candidates = targets
.Where(m => m.Id != this._unreachableTargetId || DateTime.UtcNow >= this._unreachableTargetUntilUtc)
.OrderBy(m => m.GetDistanceTo(this._player))
.Take(this.IsBot ? 2 : 1)
.ToList();
// A bot chooses randomly among the two nearest candidates instead of strictly the nearest
// one: with many bots on one ground, deterministic nearest-first makes them all dogpile the
// same monster and roam as a pack, which looks distinctly bot-like and wastes damage on
// overkill. A player's own offline session keeps hitting the nearest monster - it is his
// character's hunting efficiency, not a crowd to camouflage.
this._currentTarget = candidates.SelectRandom();
this._nearbyMonsterCount = targets.Count;
}
else
{
this._nearbyMonsterCount = this.GetAttackableMonstersInHuntingRange().Count();
this._nearbyMonsterCount = this.GetAttackableTargetsInHuntingRange().Count();
}
}
@@ -214,11 +492,42 @@ public sealed class CombatHandler
return [];
}
return map.GetAttackablesInRange(this._originPosition, this.HuntingRange)
return map.GetAttackablesInRange(this.OriginPosition, this.HuntingRange)
.OfType<Monster>()
.Where(this.IsMonsterAttackable);
}
/// <summary>
/// The regular target pool are the attackable monsters; inside a mini game which allows player
/// killing (Chaos Castle) the other participants join it - there everyone is opposition, and a
/// bot which placidly farms monsters while being cut down would be the obvious odd one out.
/// </summary>
private IEnumerable<IAttackable> GetAttackableTargetsInHuntingRange()
{
if (this._player.CurrentMap is not { } map)
{
return [];
}
var freeForAll = this._player.CurrentMiniGame is { AllowPlayerKilling: true };
return map.GetAttackablesInRange(this.OriginPosition, this.HuntingRange)
.Where(attackable => attackable switch
{
Monster monster => this.IsMonsterAttackable(monster),
Player player => freeForAll && this.IsEventRivalAttackable(player),
_ => false,
});
}
private bool IsEventRivalAttackable(Player target)
{
return !ReferenceEquals(target, this._player)
&& target.IsAlive
&& !target.IsAtSafezone()
&& !target.IsTeleporting
&& BotPvpRules.IsLegalPvpTarget(this._player, target);
}
private bool IsTargetInAttackRange(IAttackable target, byte range)
{
return target.IsInRange(this._player.Position, range);
@@ -226,17 +535,51 @@ public sealed class CombatHandler
private bool IsTargetStillValid(IAttackable target)
{
// A player target must stay legal for the whole fight: the self-defense window can expire
// mid-fight (the player stopped hitting back and ran), and every further strike past that
// point would be an unprovoked attack that escalates the bot's own hero state.
if (target is Player playerTarget && !BotPvpRules.IsLegalPvpTarget(this._player, playerTarget))
{
return false;
}
return target.IsAlive
&& !target.IsAtSafezone()
&& !target.IsTeleporting
&& target.IsInRange(this._originPosition, this.HuntingRange);
&& target.IsInRange(this.OriginPosition, this.HuntingRange);
}
private bool IsMonsterAttackable(Monster monster)
{
return monster.IsAlive
&& !monster.IsAtSafezone()
&& monster.Definition.ObjectKind == NpcObjectKind.Monster;
&& monster.Definition.ObjectKind == NpcObjectKind.Monster
&& this.IsWithinSafeHuntLevel(monster);
}
/// <summary>
/// With <see cref="IMuHelperSettings.OnlyHuntSafeMonsters"/> (server-side bots), the combat AI only
/// engages monsters which pass the same <see cref="IsSafeTarget"/> check the bot navigator hunts by.
/// Without this, a bot travelling through hostile territory picks a fight with any monster that
/// comes within range - including ones far too strong - and dies. Human offline sessions keep the
/// unrestricted behavior, since the player chose their hunting spot deliberately.
/// </summary>
private bool IsWithinSafeHuntLevel(Monster monster)
{
if (this._config?.OnlyHuntSafeMonsters != true)
{
return true;
}
if (this._player.CurrentMiniGame is not null)
{
// Inside a mini game event the opposition is not the bot's choice - it fights what
// the event throws at it, like every other participant. Refusing "unsafe" waves
// would leave the bot idling in the middle of a Blood Castle.
return true;
}
return IsSafeTarget(this._player, monster.Definition);
}
private async ValueTask ExecutePhysicalAttackAsync(IAttackable target)
@@ -274,6 +617,36 @@ public sealed class CombatHandler
{
await monster.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false);
}
// A player target is hit by the area skill as well. Outside of free-for-all events ONLY the
// target itself (the self-defense aggressor): any bystanding player in the blast radius is
// deliberately spared - a bot's self-defense must never splash uninvolved players, no
// matter what it casts. Inside a mini game with free player killing (Chaos Castle) there
// are no uninvolved players, so the skill splashes the other participants like any area
// skill would. The legality re-check right at the strike closes the last race: the target
// was legal when it was picked, but the situation may have changed in the meantime.
IEnumerable<Player> playerTargets;
if (this._player.CurrentMiniGame is { AllowPlayerKilling: true })
{
playerTargets = this._player.CurrentMap?
.GetAttackablesInRange(target.Position, skill.Range)
.OfType<Player>()
.Where(p => !ReferenceEquals(p, this._player))
?? [];
}
else
{
playerTargets = target is Player playerTarget ? [playerTarget] : [];
}
foreach (var player in playerTargets)
{
if (player.IsAlive && !player.IsAtSafezone()
&& BotPvpRules.IsLegalPvpTarget(this._player, player))
{
await player.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false);
}
}
}
private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skill skill)
@@ -290,10 +663,12 @@ public sealed class CombatHandler
return null;
}
// If no skills are configured at all, don't attack.
// If no skills are configured at all, don't attack - unless the AI is allowed to pick a skill
// on its own (bots), in which case we fall through to the automatic selection below.
if (this._config.BasicSkillId == 0
&& this._config.ActivationSkill1Id == 0
&& this._config.ActivationSkill2Id == 0)
&& this._config.ActivationSkill2Id == 0
&& !this._config.AutoSelectBestSkill)
{
return null;
}
@@ -316,9 +691,64 @@ public sealed class CombatHandler
}
}
// No explicitly configured skill fired: let the AI pick the strongest affordable learned attack
// skill. This scales with the character's level and mana pool, so higher-level bots naturally cast
// stronger spells, and drop back to a basic attack (via FallbackBasicAttack) only when out of mana.
if (this._config.AutoSelectBestSkill)
{
return this.SelectBestAffordableSkill();
}
return null;
}
/// <summary>
/// Picks the strongest attack skill the character has learned and can currently afford (enough mana
/// and ability). Only attack skills (direct hit or area damage) are considered; learned skills are
/// always class-qualified, so this can never cast a skill the class is not entitled to.
/// </summary>
private SkillEntry? SelectBestAffordableSkill()
{
if (this._tickBestSkillComputed)
{
// Computed once per tick: both the attack-range check and the attack itself need it.
return this._tickBestSkill;
}
this._tickBestSkillComputed = true;
if (this._player.SkillList is not { } skillList)
{
return null;
}
SkillEntry? best = null;
var bestDamage = 0;
foreach (var entry in skillList.Skills)
{
if (entry.Skill is not { } skill || skill.AttackDamage <= 0)
{
continue;
}
if (skill.SkillType is not (SkillType.DirectHit
or SkillType.AreaSkillAutomaticHits
or SkillType.AreaSkillExplicitHits
or SkillType.AreaSkillExplicitTarget))
{
continue;
}
if (skill.AttackDamage > bestDamage && this.HasEnoughResources(entry))
{
best = entry;
bestDamage = skill.AttackDamage;
}
}
this._tickBestSkill = best;
return best;
}
/// <summary>
/// Evaluates whether the skill in the given slot should fire this tick.
/// </summary>
@@ -514,6 +944,15 @@ public sealed class CombatHandler
}
}
// Bots have no configured skill IDs but auto-select their attack skill; use the range of the skill
// they would actually cast now, so ranged casters attack from a distance instead of closing to melee.
if (this._config.AutoSelectBestSkill
&& this.SelectBestAffordableSkill()?.Skill?.Range is { } autoRange
&& autoRange > 0)
{
return (byte)autoRange;
}
if (this._player.Attributes is { } attributes
&& (attributes[Stats.IsBowEquipped] > 0 || attributes[Stats.IsCrossBowEquipped] > 0))
{

View File

@@ -18,6 +18,9 @@ using MUnique.OpenMU.Interfaces;
/// </summary>
public sealed class HealingHandler
{
/// <summary>Drink a mana potion once mana falls below this share, so casters can keep casting.</summary>
private const int ManaThresholdPercent = 30;
private static readonly ItemConsumeAction ConsumeAction = new();
private static readonly ItemIdentifier[] HealthPotionPriority =
@@ -28,6 +31,13 @@ public sealed class HealingHandler
ItemConstants.Apple,
];
private static readonly ItemIdentifier[] ManaPotionPriority =
[
ItemConstants.LargeManaPotion,
ItemConstants.MediumManaPotion,
ItemConstants.SmallManaPotion,
];
private readonly OfflinePlayer _player;
private readonly IMuHelperSettings? _config;
@@ -75,7 +85,25 @@ public sealed class HealingHandler
if (this._config!.UseHealPotion && this.IsHealthBelowThreshold(this._player, this._config.PotionThresholdPercent))
{
await this.UseHealthPotionAsync().ConfigureAwait(false);
return;
}
if (this._config.UseManaPotion && this.IsManaBelowThreshold())
{
await this.UsePotionAsync(ManaPotionPriority).ConfigureAwait(false);
}
}
private bool IsManaBelowThreshold()
{
if (this._player.Attributes is not { } attributes)
{
return false;
}
double mana = attributes[Stats.CurrentMana];
double maxMana = attributes[Stats.MaximumMana];
return maxMana > 0 && (mana * 100.0 / maxMana) <= ManaThresholdPercent;
}
private async ValueTask PerformPartyHealingAsync()
@@ -126,14 +154,16 @@ public sealed class HealingHandler
return maxHp > 0 && (hp * 100.0 / maxHp) <= thresholdPercent;
}
private async ValueTask UseHealthPotionAsync()
private ValueTask UseHealthPotionAsync() => this.UsePotionAsync(HealthPotionPriority);
private async ValueTask UsePotionAsync(ItemIdentifier[] priority)
{
if (this._player.Inventory is null)
{
return;
}
foreach (var identifier in HealthPotionPriority)
foreach (var identifier in priority)
{
var potion = this._player.Inventory.Items
.FirstOrDefault(i => i.Definition?.Group == identifier.Group

View File

@@ -120,13 +120,22 @@ public sealed class ItemPickupHandler
return true;
}
if (this._config.PickAncient && item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0))
var isAncient = item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0);
var isExcellent = item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent);
if ((this._config.PickAncient && isAncient) || (this._config.PickExcellent && isExcellent))
{
return true;
// A human's helper hoards every excellent/ancient piece - its owner sorts the treasure
// out later. A bot has no later: it cannot trade, so it only takes what it can actually
// wear as an upgrade; everything else would silt up its backpack until the loot pickup
// stops.
return this._player.Account?.IsBot != true
|| Bots.BotEquipmentHandler.IsUpgradeFor(this._player, item);
}
if (this._config.PickExcellent && item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent))
if (this._config.PickUpgradeItems && Bots.BotEquipmentHandler.IsUpgradeFor(this._player, item))
{
// The item is class-qualified gear which beats what the bot currently wears - worth picking
// up; the BotEquipmentHandler will equip it on one of its next passes.
return true;
}

View File

@@ -16,7 +16,6 @@ public sealed class MovementHandler
private readonly OfflinePlayer _player;
private readonly IMuHelperSettings? _config;
private readonly Point _originPosition;
private DateTime? _outOfRangeSince;
@@ -25,14 +24,17 @@ public sealed class MovementHandler
/// </summary>
/// <param name="player">The offline player.</param>
/// <param name="config">The MU Helper configuration.</param>
/// <param name="originPosition">The original spawn position.</param>
public MovementHandler(OfflinePlayer player, IMuHelperSettings? config, Point originPosition)
public MovementHandler(OfflinePlayer player, IMuHelperSettings? config)
{
this._player = player;
this._config = config;
this._originPosition = originPosition;
}
/// <summary>
/// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds.
/// </summary>
private Point OriginPosition => this._player.HuntingOrigin;
/// <summary>
/// Gets the hunting range in tiles.
/// </summary>
@@ -51,7 +53,7 @@ public sealed class MovementHandler
if (this.ShouldRegroup(out var distance))
{
await this.WalkToAsync(this._originPosition).ConfigureAwait(false);
await this.WalkToAsync(this.OriginPosition).ConfigureAwait(false);
this._outOfRangeSince = null;
return false;
}
@@ -69,13 +71,43 @@ public sealed class MovementHandler
/// </summary>
/// <param name="target">The target to move closer to.</param>
/// <param name="range">The range to stop within.</param>
public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range)
/// <returns>True, if a walk towards the target was started; false, if no path exists or walking is not possible.</returns>
public async ValueTask<bool> MoveCloserToTargetAsync(IAttackable target, byte range)
{
if (this._player.CurrentMap is { } map && target.IsInRange(this._originPosition, this.HuntingRange))
if (this._player.CurrentMap is { } map && target.IsInRange(this.OriginPosition, this.HuntingRange))
{
var walkTarget = map.Terrain.GetRandomCoordinate(target.Position, range);
await this.WalkToAsync(walkTarget).ConfigureAwait(false);
var walkTarget = GetApproachPoint(map, this._player.Position, target.Position, range);
return await this.WalkToAsync(walkTarget).ConfigureAwait(false);
}
return false;
}
/// <summary>
/// Picks the point to walk to when closing in on a target: straight along the line towards it,
/// stopping at attack range. The previous behavior re-randomized a point around the target every
/// tick, which made the character zig-zag visibly towards its prey and re-path constantly.
/// Falls back to a random point near the target when the straight-line point is not walkable.
/// </summary>
private static Point GetApproachPoint(GameMap map, Point from, Point to, byte stopRange)
{
var dx = to.X - from.X;
var dy = to.Y - from.Y;
var distance = Math.Max(Math.Abs(dx), Math.Abs(dy));
if (distance <= stopRange)
{
return from;
}
var factor = (double)(distance - stopRange) / distance;
var x = (byte)Math.Clamp(from.X + (int)Math.Round(dx * factor), 0, 255);
var y = (byte)Math.Clamp(from.Y + (int)Math.Round(dy * factor), 0, 255);
if (map.Terrain.WalkMap[x, y] && !map.Terrain.SafezoneMap[x, y])
{
return new Point(x, y);
}
return map.Terrain.GetRandomCoordinate(to, stopRange);
}
/// <summary>
@@ -120,7 +152,7 @@ public sealed class MovementHandler
private bool ShouldRegroup(out double distance)
{
distance = this._player.GetDistanceTo(this._originPosition);
distance = this._player.GetDistanceTo(this.OriginPosition);
if (distance <= RegroupDistanceThreshold)
{
return false;

View File

@@ -7,16 +7,72 @@ namespace MUnique.OpenMU.GameLogic.Offline;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// An offline player that continues leveling after the real client disconnects.
/// </summary>
public sealed class OfflinePlayer : Player
public class OfflinePlayer : Player
{
/// <summary>
/// A player who killed this bot this many times gets no (further) revenge: walking back a third
/// time into the same lost fight would just be a death loop feeding the killer free kills.
/// </summary>
private const int RepeatedKillThreshold = 2;
/// <summary>
/// How long an attack by a player stays "hot" as a self-defense target, counted from the LAST hit
/// (every attack refreshes it). Long enough to hold a grudge: an attacker who breaks off and comes
/// back within this window stays the bot's priority target instead of being forgiven after
/// seconds - whether it may actually be struck is decided per attack by <see cref="Bots.BotPvpRules"/>.
/// </summary>
private static readonly TimeSpan AggressionMemory = TimeSpan.FromMinutes(5);
/// <summary>
/// How long a revenge stays armed after the respawn. One attempt only: if the bot has not reached
/// its death site within this time (long routes, fights on the way), it gives up and hunts normally.
/// </summary>
private static readonly TimeSpan RevengeDuration = TimeSpan.FromMinutes(3);
/// <summary>
/// How long the bot keeps away from hunting grounds near its death site after the same player
/// killed it repeatedly (see <see cref="RepeatedKillThreshold"/>).
/// </summary>
private static readonly TimeSpan DeathSiteAvoidanceDuration = TimeSpan.FromMinutes(10);
/// <summary>
/// How long a death counts toward <see cref="RepeatedKillThreshold"/>. A kill by a player the bot
/// has not seen for this long counts as a fresh grudge again, not as a repeated one.
/// </summary>
private static readonly TimeSpan DeathCountMemory = TimeSpan.FromMinutes(30);
/// <summary>
/// How often each (human) player killed this bot recently, keyed by character name. Written by the
/// death plugin and read by the AI ticks, hence concurrent.
/// </summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DeathRecord> _deathsByKiller = new();
private OfflinePlayerMuHelper? _intelligence;
private Task? _intelligenceDisposeTask;
/// <summary>
/// The player who most recently attacked this bot, with the time of that attack. Written from the
/// attack path and read from the AI tick; immutable and written atomically (a single reference
/// store), so the two can access it without a lock and without a torn <see cref="DateTime"/> read.
/// </summary>
private volatile Aggression? _aggression;
/// <summary>
/// The pending (not yet armed, <see cref="RevengeState.ExpiresAtUtc"/> is null) or armed revenge.
/// The state object is immutable and the field is written atomically, so the death plugin and the
/// AI ticks can access it without a lock.
/// </summary>
private volatile RevengeState? _revenge;
/// <summary>See <see cref="TryGetDeathSiteToAvoid"/>; immutable and written atomically, like <see cref="_revenge"/>.</summary>
private volatile DeathSite? _deathSiteToAvoid;
/// <summary>
/// Initializes a new instance of the <see cref="OfflinePlayer"/> class.
/// </summary>
@@ -36,6 +92,71 @@ public sealed class OfflinePlayer : Player
/// </summary>
public DateTime StartTimestamp { get; internal set; }
/// <summary>
/// Gets or sets the position the intelligence hunts around. For a plain offline player this is
/// the spawn position and never changes. Bots update it to roam between hunting grounds.
/// </summary>
public Point HuntingOrigin { get; set; }
/// <summary>
/// Gets a value indicating whether the player should keep playing after dying and respawning.
/// A normal offline session ends on death; bots override this to keep running forever.
/// </summary>
public virtual bool RespawnAndContinue => false;
/// <summary>
/// Gets actions queued from outside the AI tick (e.g. skill learning on level-up), which the
/// <see cref="OfflinePlayerMuHelper"/> drains at the start of each tick. This serializes such
/// mutations with the combat handler, so e.g. the skill list is never modified while combat is
/// enumerating it.
/// </summary>
internal System.Collections.Concurrent.ConcurrentQueue<Func<ValueTask>> PendingBotActions { get; } = new();
/// <summary>
/// Gets the player who most recently attacked this bot (self-defense target), if the aggression
/// is recent enough and the aggressor is still a viable target.
/// </summary>
internal Player? RecentAggressor
{
get
{
if (this._aggression is { } aggression
&& DateTime.UtcNow - aggression.AtUtc <= AggressionMemory
&& aggression.Aggressor.IsAlive
&& !aggression.Aggressor.IsAtSafezone())
{
return aggression.Aggressor;
}
return null;
}
}
/// <summary>
/// Gets or sets the pending party invitation from a player, scheduled by
/// <see cref="Bots.BotPartyHandler"/> and executed with a human-like delay in the bot's tick.
/// </summary>
internal Bots.PendingPartyInvite? PendingPartyInvite { get; set; }
/// <summary>
/// Gets or sets the time at which the bot gets bored of its current party with a human player
/// and politely leaves it (managed by <see cref="Bots.BotPartyHandler"/>).
/// </summary>
internal DateTime? PartyBoredomAtUtc { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the bot is currently on a shopping trip (walking to
/// or trading with a merchant), maintained by <see cref="Bots.BotNavigator"/>. While on an errand
/// the bot declines party invitations, like a busy player would.
/// </summary>
internal bool IsOnShoppingTrip { get; set; }
/// <summary>
/// Gets a value indicating whether a revenge against a player killer is pending or armed - the
/// bot has unfinished business and is in no mood to group up.
/// </summary>
internal bool HasRevengeIntent => this._revenge is not null;
/// <summary>
/// Initializes the offline player by loading the account fresh from the database.
/// </summary>
@@ -70,6 +191,8 @@ public sealed class OfflinePlayer : Player
await this.ClientReadyAfterMapChangeAsync().ConfigureAwait(false);
this.HuntingOrigin = this.Position;
this.StartIntelligence();
this.Logger.LogDebug(
@@ -90,11 +213,188 @@ public sealed class OfflinePlayer : Player
/// <summary>
/// Stops the offline player and removes it from the world.
/// </summary>
public async ValueTask StopAsync()
public virtual async ValueTask StopAsync()
{
await this.DisconnectAsync().ConfigureAwait(false);
}
/// <summary>
/// Registers a player who attacked this bot, so the combat AI can defend itself.
/// </summary>
/// <param name="aggressor">The player who attacked this bot.</param>
internal void RegisterAggressor(Player aggressor)
{
this._aggression = new Aggression(aggressor, DateTime.UtcNow);
}
/// <summary>
/// Registers that a (human) player killed this bot. The first kill makes a revenge pending: after
/// respawning on the same map, the bot marches back to the place of its death (driven by the
/// <see cref="Bots.BotNavigator"/>) with re-armed aggressor memory, so it attacks the killer on
/// sight. A repeated kill by the same player (see <see cref="RepeatedKillThreshold"/>) cancels
/// revenge instead and makes the bot avoid hunting grounds near the death site for a while.
/// </summary>
/// <param name="killer">The player who killed this bot.</param>
internal void RegisterDeathByPlayer(Player killer)
{
if (this.CurrentMap?.Definition is not { } deathMap)
{
return;
}
var now = DateTime.UtcNow;
var deathPosition = this.Position;
var record = this._deathsByKiller.AddOrUpdate(
killer.Name,
_ => new DeathRecord(1, now),
(_, existing) => now - existing.LastDeathUtc > DeathCountMemory
? new DeathRecord(1, now)
: new DeathRecord(existing.Count + 1, now));
if (record.Count >= RepeatedKillThreshold)
{
this._revenge = null;
this._deathSiteToAvoid = new DeathSite(deathPosition, deathMap, now + DeathSiteAvoidanceDuration);
this.Logger.LogInformation(
"Bot '{Name}' was killed by '{Killer}' again; giving up on revenge and avoiding the area around {Position} for a while.",
this.Name,
killer.Name,
deathPosition);
return;
}
this._revenge = new RevengeState(killer, deathPosition, deathMap, null);
}
/// <summary>
/// Arms a pending revenge once the bot respawned, called by the <see cref="OfflinePlayerMuHelper"/>
/// when a bot resumes after death. Only a respawn on the map the bot died on qualifies (from any
/// other map the march back would be meaningless); the aggressor memory is re-armed, so the combat
/// AI keeps the killer prioritized (struck only when legal, see <see cref="Bots.BotPvpRules"/>),
/// and the revenge gets its time-to-live.
/// </summary>
internal void ArmRevengeAfterRespawn()
{
if (this._revenge is not { ExpiresAtUtc: null } revenge)
{
return;
}
if (!object.Equals(this.CurrentMap?.Definition, revenge.DeathMap))
{
this._revenge = null;
return;
}
this._revenge = revenge with { ExpiresAtUtc = DateTime.UtcNow + RevengeDuration };
this.RegisterAggressor(revenge.Killer);
this.Logger.LogInformation("Bot '{Name}' returns to avenge its death against '{Killer}'.", this.Name, revenge.Killer.Name);
}
/// <summary>
/// Gets the destination of an armed, still running revenge. Expires the revenge when its
/// time-to-live ran out or the bot is no longer on the map it died on (e.g. it warped away).
/// </summary>
/// <param name="currentMap">The map the bot is currently on.</param>
/// <param name="deathSite">The place of the bot's death to march back to.</param>
/// <returns><c>true</c> if a revenge is active and <paramref name="deathSite"/> was set.</returns>
internal bool TryGetRevengeDestination(GameMapDefinition currentMap, out Point deathSite)
{
deathSite = default;
if (this._revenge is not { ExpiresAtUtc: { } expiresAt } revenge)
{
return false;
}
if (DateTime.UtcNow > expiresAt)
{
this.ExpireRevenge("it timed out before the bot reached the death site");
return false;
}
if (!object.Equals(currentMap, revenge.DeathMap))
{
this.ExpireRevenge("the bot left the map it died on");
return false;
}
deathSite = revenge.DeathPosition;
return true;
}
/// <summary>
/// Ends an active revenge - the single attempt is spent, the bot returns to its normal routine.
/// The aggressor memory is deliberately left armed: if the killer is still around, the combat AI
/// engages it, and if it strikes again, self-defense re-arms the memory anyway.
/// </summary>
/// <param name="reason">Why the revenge ended, for the log.</param>
internal void ExpireRevenge(string reason)
{
if (this._revenge is { } revenge)
{
this._revenge = null;
this.Logger.LogInformation("Bot '{Name}' revenge against '{Killer}' ended: {Reason}.", this.Name, revenge.Killer.Name, reason);
}
}
/// <summary>
/// Gets the death site the bot should keep away from when picking a hunting ground - set after the
/// same player killed it repeatedly, so it stops walking back into the same lost fight.
/// </summary>
/// <param name="currentMap">The map the bot is currently on.</param>
/// <param name="deathSite">The place of the repeated deaths.</param>
/// <returns><c>true</c> if an avoidance is active on the given map and <paramref name="deathSite"/> was set.</returns>
internal bool TryGetDeathSiteToAvoid(GameMapDefinition currentMap, out Point deathSite)
{
deathSite = default;
if (this._deathSiteToAvoid is not { } site
|| DateTime.UtcNow > site.AvoidUntilUtc
|| !object.Equals(currentMap, site.Map))
{
return false;
}
deathSite = site.Position;
return true;
}
/// <summary>
/// Executes and removes all queued <see cref="PendingBotActions"/>.
/// </summary>
internal async ValueTask DrainPendingBotActionsAsync()
{
while (this.PendingBotActions.TryDequeue(out var action))
{
try
{
await action().ConfigureAwait(false);
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Queued bot action failed for {Account}.", this.AccountLoginName);
}
}
}
/// <summary>
/// Called when an AI tick of this player finished without an exception. Does nothing here - a bot
/// uses it to forget earlier failures (see <see cref="Bots.BotPlayer"/>).
/// </summary>
internal virtual void OnAiTickSucceeded()
{
// Nothing to do for a plain offline player.
}
/// <summary>
/// Called when an AI tick of this player threw. Does nothing here, so the human offline mode keeps
/// behaving exactly as before; a bot counts the failures and asks for a restart when they don't stop
/// (see <see cref="Bots.BotPlayer"/>).
/// </summary>
internal virtual void OnAiTickFailed()
{
// Nothing to do for a plain offline player.
}
/// <inheritdoc />
protected override async ValueTask InternalDisconnectAsync()
{
@@ -132,6 +432,15 @@ public sealed class OfflinePlayer : Player
protected override ICustomPlugInContainer<IViewPlugIn> CreateViewPlugInContainer()
=> new OfflineViewPlugInContainer(this);
/// <summary>
/// Starts the intelligence which drives this offline player. Overridden by bots to also run navigation.
/// </summary>
protected virtual void StartIntelligence()
{
this._intelligence = new OfflinePlayerMuHelper(this);
this._intelligence.Start();
}
private async ValueTask AdvanceToCharacterSelectionStateAsync()
{
// Advance state to allow the intelligence to perform actions.
@@ -146,9 +455,24 @@ public sealed class OfflinePlayer : Player
await this.SetSelectedCharacterAsync(character).ConfigureAwait(false);
}
private void StartIntelligence()
{
this._intelligence = new OfflinePlayerMuHelper(this);
this._intelligence.Start();
}
}
/// <summary>
/// How often (and how recently) a specific player killed this bot.
/// </summary>
private sealed record DeathRecord(int Count, DateTime LastDeathUtc);
/// <summary>
/// A revenge for a death by a player's hand: pending while <see cref="ExpiresAtUtc"/> is null
/// (the bot has not respawned yet), armed and running once it is set.
/// </summary>
private sealed record RevengeState(Player Killer, Point DeathPosition, GameMapDefinition DeathMap, DateTime? ExpiresAtUtc);
/// <summary>
/// A death site the bot avoids when picking hunting grounds, after repeated deaths there.
/// </summary>
private sealed record DeathSite(Point Position, GameMapDefinition Map, DateTime AvoidUntilUtc);
/// <summary>
/// The most recent aggression against this bot: who attacked and when.
/// </summary>
private sealed record Aggression(Player Aggressor, DateTime AtUtc);
}

View File

@@ -46,14 +46,13 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
public OfflinePlayerMuHelper(OfflinePlayer player)
{
this._player = player;
var originalPosition = player.Position;
var config = player.MuHelperSettings;
this._buffHandler = new BuffHandler(player, config);
this._healingHandler = new HealingHandler(player, config);
this._itemPickupHandler = new ItemPickupHandler(player, config);
this._movementHandler = new MovementHandler(player, config, originalPosition);
this._combatHandler = new CombatHandler(player, config, this._movementHandler, originalPosition);
this._movementHandler = new MovementHandler(player, config);
this._combatHandler = new CombatHandler(player, config, this._movementHandler);
this._repairHandler = new RepairHandler(player, config);
this._zenHandler = new ZenConsumptionHandler(player);
this._petHandler = new PetHandler(player, config);
@@ -120,10 +119,17 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
{
this._player.Logger.LogDebug("Offline player '{Name}' died. Killer: {KillerName}.", this._player.Name, e.KillerName);
this._isDead = true;
// Do not cancel the loop here: a bot needs to keep ticking so it can resume after respawning.
// For a normal offline session the tick stops the session on respawn, which disposes (and cancels) this helper.
}
private async Task RunLoopAsync(CancellationToken cancellationToken)
{
// Randomize the loop phase, so hundreds of concurrently started players don't all tick on the
// same 500ms boundary - smoother server load and less robotic synchrony between them.
await Task.Delay(Rand.NextInt(0, 500), cancellationToken).ConfigureAwait(false);
while (await this._timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
cancellationToken.ThrowIfCancellationRequested();
@@ -136,6 +142,7 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
try
{
await this.TickAsync(cancellationToken).ConfigureAwait(false);
this._player.OnAiTickSucceeded();
}
catch (OperationCanceledException)
{
@@ -144,11 +151,16 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
catch (Exception ex)
{
this._player.Logger.LogError(ex, "Error in offline player helper tick for {AccountLoginName}.", this._player.AccountLoginName);
this._player.OnAiTickFailed();
}
}
private async ValueTask TickAsync(CancellationToken cancellationToken)
{
// Actions queued from outside the tick (e.g. skill learning on level-up) run here, serialized
// with the combat handler - so nothing mutates the skill list while combat is enumerating it.
await this._player.DrainPendingBotActionsAsync().ConfigureAwait(false);
if (await this.HandleDeathAsync().ConfigureAwait(false))
{
return;
@@ -215,6 +227,19 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
return true;
}
if (this._player.RespawnAndContinue)
{
// Bots keep playing: reset the death state and re-anchor the hunting origin to the respawn
// position so the navigator picks a fresh hunting ground from where the bot came back to life.
// A death by a player's hand may have left a pending revenge - arm it now (the navigator
// then marches the bot back to its death site instead of picking a hunting ground).
this._isDead = false;
this._player.HuntingOrigin = this._player.Position;
this._player.ArmRevengeAfterRespawn();
this._player.Logger.LogInformation("Bot '{Name}' respawned; resuming.", this._player.Name);
return false;
}
if (this._player.Account?.LoginName is { } loginName)
{
this._player.Logger.LogInformation("Offline player died and successfully respawned. Stopping session for {0}.", loginName);

View File

@@ -34,6 +34,14 @@ internal sealed class ZenConsumptionHandler
/// <returns><c>true</c> if the player can continue; <c>false</c> if insufficient Zen.</returns>
public async ValueTask<bool> DeductZenAsync()
{
// Bots are exempt from the PC-Cafe fee: they don't accumulate Zen fast enough
// to cover it and would otherwise go bankrupt and stop. Human offline-leveling
// players (IsBot == false) keep paying as before.
if (this._player.Account?.IsBot == true)
{
return true;
}
if (DateTime.UtcNow - this._lastPayTimestamp < this._configuration.PayInterval)
{
return true;

View File

@@ -263,18 +263,7 @@ public class MoveItemAction
if (itemDefinition.ItemSlot.ItemSlots.Contains(toSlot) &&
player.CompliesRequirements(item))
{
static bool IsOneHandedOrShield(ItemDefinition definition) =>
(definition.ItemSlot!.ItemSlots.Contains(RightHandSlot) && definition.ItemSlot.ItemSlots.Contains(LeftHandSlot)) || definition.Group == 6;
var rightHandItemDefinition = storage.GetItem(RightHandSlot)?.Definition!;
if ((toSlot == LeftHandSlot
&& itemDefinition.Width >= 2
&& rightHandItemDefinition != null
&& !rightHandItemDefinition.IsAmmunition)
|| (toSlot == RightHandSlot
&& IsOneHandedOrShield(itemDefinition)
&& storage.GetItem(LeftHandSlot)?.Definition!.Width >= 2))
if (itemDefinition.ConflictsWithEquippedHands(storage, toSlot))
{
// Attempting to equip a two-handed item to the left hand slot when a shield is in the right hand slot,
// or trying to equip a one-handed weapon or shield to the right hand slot when a two-handed item is in the left hand slot.

View File

@@ -99,6 +99,10 @@ public class EnterMiniGameAction
var entrance = miniGameDefinition.Entrance ?? throw new InvalidOperationException("mini game entrance not defined");
var miniGame = await player.GameContext.GetMiniGameAsync(miniGameDefinition, player).ConfigureAwait(false);
// Snapshot before entering: an event which disallows parties (Chaos Castle) kicks the
// entering player out of its party below, losing the knowledge of who was going to follow.
var partyBots = Bots.BotMiniGameHandler.SnapshotPartyBots(player);
var enterResult = await miniGame.TryEnterAsync(player).ConfigureAwait(false);
if (enterResult == EnterResult.Success)
{
@@ -125,6 +129,10 @@ public class EnterMiniGameAction
await player.RemoveSummonAsync().ConfigureAwait(false);
await player.MagicEffectList.ClearEffectsAfterDeathAsync().ConfigureAwait(false);
await player.WarpToAsync(entrance).ConfigureAwait(false);
// The bots of the entering party leader follow them in (each checked against the same
// entry restrictions, no ticket needed - the leader's own ticket legitimizes the visit).
Bots.BotMiniGameHandler.BringPartyBotsAlong(player, partyBots, miniGameDefinition, miniGame);
}
else
{

View File

@@ -28,7 +28,12 @@ public class PartyRequestAction
if (toRequest.Party != null || toRequest.LastPartyRequester != null)
{
if (toRequest.Party != null && Equals(toRequest.Party.PartyMaster, toRequest))
// A server-side bot is asked as well when it is a plain member of its (bot) party: a living
// player takes precedence over the bot's own company, so it leaves that party and joins the
// inviter (see BotPartyHandler). Everyone else keeps the original rule - only the master of a
// party can answer an invitation.
var isBot = toRequest.Account?.IsBot == true;
if (toRequest.Party != null && (isBot || Equals(toRequest.Party.PartyMaster, toRequest)))
{
if (await PartyRequestHandler.TryAutoAcceptPartyRequestAsync(toRequest, player).ConfigureAwait(false))
{

View File

@@ -29,8 +29,13 @@ public abstract class UnlockCharacterAtLevelBase : ICharacterLevelUpPlugIn
/// <inheritdoc />
public void CharacterLeveledUp(Player player)
{
// Server-side bots are excluded: they never create new characters, and animating several
// characters of one account concurrently (each with its own persistence context and thus its
// own stale copy of the account) lets two siblings pass the duplicate check below at the same
// time - both insert the same unlock row and the account's saves keep failing with a duplicate
// key of "PK_AccountCharacterClass" until the next restart.
if (player.Level >= this._minimumLevel
&& player.Account is { } account
&& player.Account is { IsBot: false } account
&& account.UnlockedCharacterClasses.All(c => c.Number != this._classNumber))
{
var unlockedClass = player.GameContext.Configuration.CharacterClasses.FirstOrDefault(c => c.Number == this._classNumber);

View File

@@ -155,6 +155,26 @@ public sealed class MuHelperSettings : IMuHelperSettings
/// <summary>Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used.</summary>
public bool FallbackBasicAttack { get; init; }
/// <inheritdoc />
/// <remarks>Human MU Helper sessions drive their skills through the explicit client configuration, so this is always <c>false</c>.</remarks>
public bool AutoSelectBestSkill => false;
/// <inheritdoc />
/// <remarks>Human MU Helper sessions configure their buff slots explicitly, so this is always <c>false</c>.</remarks>
public bool AutoSelectBuffs => false;
/// <inheritdoc />
/// <remarks>The client-side MU Helper has no mana potion setting; this only exists for server-side bots.</remarks>
public bool UseManaPotion => false;
/// <inheritdoc />
/// <remarks>A human picked their hunting spot deliberately, so their offline session fights whatever is there.</remarks>
public bool OnlyHuntSafeMonsters => false;
/// <inheritdoc />
/// <remarks>Equipment progression is a server-side bot behavior only.</remarks>
public bool PickUpgradeItems => false;
/// <inheritdoc />
public override string ToString()
{

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
// <copyright file="20260627120000_AddAccountIsBot.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
using Microsoft.EntityFrameworkCore.Migrations;
/// <inheritdoc />
public partial class AddAccountIsBot : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsBot",
schema: "data",
table: "Account",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsBot",
schema: "data",
table: "Account");
}
}
}

View File

@@ -35,6 +35,9 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsBot")
.HasColumnType("boolean");
b.Property<bool>("IsTemplate")
.HasColumnType("boolean");