// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic.Bots; using System.Collections.Concurrent; using System.Linq; using MUnique.OpenMU.GameLogic.Offline; /// /// Manages the lifecycle of server-side bots. /// /// /// A bot reuses the connection-less 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 ), 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 /offlevel 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). /// public sealed class BotManager { private readonly ConcurrentDictionary _bots = new(StringComparer.OrdinalIgnoreCase); /// /// Gets a snapshot of the currently active bots. /// public IReadOnlyCollection Bots => this._bots.Values.ToList(); /// /// Gets the number of currently active bots, without taking a snapshot of them - the periodic task /// asks every second whether there is anything to stop. /// public int BotCount => this._bots.Count; /// /// Spawns a bot which drives a specific character of the given account. /// /// The game context. /// The login name of an existing account. /// The character slot to drive; null drives the first character by slot. /// true if a bot was started; false if it could not be started or was already active. public async ValueTask 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; } } /// /// Stops and removes all currently active bots. /// /// The task. 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); } } } /// /// Determines whether the given character of the given account is currently animated by a bot. /// /// The account login name. /// The character slot. public bool IsActive(string loginName, byte slot) => this._bots.ContainsKey(GetKey(loginName, slot)); /// /// 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. /// /// The name of the stopped bot's character, or null if no bot was active. public async ValueTask 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; } /// /// 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. /// /// The game context. /// The bot to restart. /// true if the bot came back online. public async ValueTask 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); } /// /// 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 /// ), 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. /// /// The game context (provides the party manager). 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 { 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}"; /// /// 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 ; the removal /// from the game context happens through the disconnect event. /// 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); } }