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