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