// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic.Bots; using Microsoft.Extensions.Logging; using MUnique.OpenMU.DataModel.Configuration; /// /// 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. /// /// 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 /// () is handed to the bots: the rest stays reserved /// for real players, who must never be denied a slot by a bot. /// /// /// 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. /// /// internal sealed class BotServerPartition { private BotServerPartition(int firstAccount, int accountCount, bool isGenerator) { this.FirstAccount = firstAccount; this.AccountCount = accountCount; this.IsGenerator = isGenerator; } /// /// Gets the one-based index of the first bot account this server animates. /// public int FirstAccount { get; } /// /// Gets the number of bot accounts this server animates. /// public int AccountCount { get; } /// /// 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. /// public bool IsGenerator { get; } /// /// Determines the share of the bot population which the given game server animates. /// /// The context of the game server which asks. /// The bot configuration. /// The logger. /// The share of this server. public static async ValueTask 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; } /// /// Determines whether this server animates the bot account with the given one-based index. /// /// The one-based bot account index. public bool Owns(int accountIndex) => accountIndex >= this.FirstAccount && accountIndex < this.FirstAccount + this.AccountCount; /// /// Hands the accounts to the servers, each getting a share PROPORTIONAL to its capacity: the pure /// decision behind . Every server runs it over the same list and gets the /// same answer, which is what makes the split need no coordination at all. /// /// 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. /// /// /// How many accounts each game server may animate, ordered by server id. /// The id of the server which asks. /// The configured number of bot accounts. /// The share of the asking server, and how many accounts fit into the deployment at all. 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); } /// /// 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. /// private static async ValueTask> GetAccountCapacitiesAsync( IGameContext gameContext, BotConfiguration configuration, int charactersPerAccount, ILogger logger) { try { using var context = gameContext.PersistenceContextProvider.CreateNewConfigurationContext(); var definitions = await context.GetAsync().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 []; } } }