// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.GameLogic.Offline; /// /// Lets a server-side bot party up with players who invite it (enabled by /// ): 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 ) until it gets bored and politely leaves. Safeguards keep it /// believable and abuse-free: 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. There is no level gate, matching OpenMU's own party /// action: it is the player who invites, and the bot leaves again once it gets bored. /// internal static class BotPartyHandler { /// Lower bound of the human-like delay before the bot answers an invitation. private static readonly TimeSpan MinAcceptDelay = TimeSpan.FromSeconds(2); /// Upper bound of the human-like delay before the bot answers an invitation. private static readonly TimeSpan MaxAcceptDelay = TimeSpan.FromSeconds(5); /// /// 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). /// private static readonly TimeSpan MinPartyDuration = TimeSpan.FromMinutes(10); /// Upper bound of the time the bot stays in a party with a human, see . private static readonly TimeSpan MaxPartyDuration = TimeSpan.FromMinutes(20); /// /// Schedules the acceptance of a party invitation to a bot, if the bot is available for it. /// Called from the auto-accept criteria of . /// /// The invited player; only server-side bots schedule an accept. /// The player who sent the party request. /// Overrides the human-like random delay (used by tests). /// True, if the invitation was taken and will be answered; false, if no criteria matched. internal static async ValueTask 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(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; } /// /// 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 ). /// /// The bot. 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; } } /// /// Determines whether the bot's party contains a human player (any live member which is not a /// server-side ). /// /// The bot. /// True, if a human player is in the bot's party. internal static bool HasHumanCompanion(Player bot) { return bot.Party is { } party && party.PartyList.OfType().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(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); } } /// /// 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 ). /// 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(Player requester) { return requester.IsAlive && requester.PlayerState.CurrentState == PlayerState.EnteredWorld; } }