560 lines
19 KiB
C#
560 lines
19 KiB
C#
// <copyright file="Party.cs" company="MUnique">
|
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
|
// </copyright>
|
|
|
|
namespace MUnique.OpenMU.GameLogic;
|
|
|
|
using System.Diagnostics.Metrics;
|
|
using System.Threading;
|
|
using MUnique.OpenMU.GameLogic.Attributes;
|
|
using MUnique.OpenMU.GameLogic.NPC;
|
|
using MUnique.OpenMU.GameLogic.Views;
|
|
using MUnique.OpenMU.GameLogic.Views.Party;
|
|
using MUnique.OpenMU.Persistence;
|
|
using Nito.AsyncEx;
|
|
|
|
/// <summary>
|
|
/// A group of players who share chat, health visibility, and experience distribution.
|
|
/// </summary>
|
|
public sealed class Party : AsyncDisposable
|
|
{
|
|
private static readonly Meter Meter = new(MeterName);
|
|
private static readonly Counter<int> PartyCount = Meter.CreateCounter<int>("PartyCount");
|
|
|
|
private readonly ILogger<Party> _logger;
|
|
private readonly IPartyManager _partyManager;
|
|
private readonly byte _maxPartySize;
|
|
|
|
private readonly object _writeLock = new();
|
|
private readonly AsyncLock _distributionLock = new();
|
|
private readonly List<Player> _distributionList;
|
|
|
|
private readonly TimeSpan _healthUpdateInterval = TimeSpan.FromMilliseconds(500);
|
|
private readonly Task? _healthUpdateTask;
|
|
private CancellationTokenSource? _healthUpdateCts;
|
|
|
|
private IPartyMember[] _partyMembers = [];
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="Party"/> class.
|
|
/// </summary>
|
|
/// <param name="partyManager">The party manager for membership tracking.</param>
|
|
/// <param name="maxPartySize">Maximum size of the party.</param>
|
|
/// <param name="logger">Logger for party events.</param>
|
|
public Party(IPartyManager partyManager, byte maxPartySize, ILogger<Party> logger)
|
|
{
|
|
this._partyManager = partyManager;
|
|
this._maxPartySize = maxPartySize;
|
|
this._logger = logger;
|
|
this._distributionList = new List<Player>(maxPartySize);
|
|
|
|
this._healthUpdateCts = new CancellationTokenSource();
|
|
this._healthUpdateTask = this.HealthUpdateLoopAsync(this._healthUpdateCts.Token);
|
|
PartyCount.Add(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the party members.
|
|
/// </summary>
|
|
public IReadOnlyList<IPartyMember> PartyList => this._partyMembers;
|
|
|
|
/// <summary>
|
|
/// Gets the maximum party size.
|
|
/// </summary>
|
|
public byte MaxPartySize => this._maxPartySize;
|
|
|
|
/// <summary>
|
|
/// Gets the party master.
|
|
/// </summary>
|
|
public IPartyMember? PartyMaster { get; private set; }
|
|
|
|
private static string MeterName => typeof(Party).FullName ?? nameof(Party);
|
|
|
|
/// <summary>
|
|
/// Adds a new member to the party.
|
|
/// </summary>
|
|
/// <param name="newMember">The member to add.</param>
|
|
/// <returns>True if the member was added successfully; false if the party is full.</returns>
|
|
public async ValueTask<bool> AddAsync(IPartyMember newMember)
|
|
{
|
|
lock (this._writeLock)
|
|
{
|
|
if (this._partyMembers.Length >= this._maxPartySize)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (this._partyMembers.Length == 0)
|
|
{
|
|
this.PartyMaster = newMember;
|
|
}
|
|
|
|
newMember.Party = this;
|
|
this._partyManager.TrackMembership(newMember.Name, this);
|
|
this._partyMembers = [.. this._partyMembers, newMember];
|
|
}
|
|
|
|
await this.SendPartyListAsync().ConfigureAwait(false);
|
|
await this.UpdateNearbyCountAsync().ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replaces <paramref name="oldMember"/> with <paramref name="newMember"/> in-place,
|
|
/// preserving the member's slot index and master status.
|
|
/// </summary>
|
|
/// <param name="oldMember">The member to replace.</param>
|
|
/// <param name="newMember">The new member to insert.</param>
|
|
public async ValueTask ReplaceMemberAsync(IPartyMember oldMember, IPartyMember newMember)
|
|
{
|
|
lock (this._writeLock)
|
|
{
|
|
var index = Array.IndexOf(this._partyMembers, oldMember);
|
|
if (index < 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var updated = (IPartyMember[])this._partyMembers.Clone();
|
|
updated[index] = newMember;
|
|
|
|
newMember.Party = this;
|
|
oldMember.Party = null;
|
|
|
|
this._partyManager.UntrackMembership(oldMember.Name);
|
|
this._partyManager.TrackMembership(newMember.Name, this);
|
|
|
|
if (this.PartyMaster == oldMember)
|
|
{
|
|
this.PartyMaster = newMember;
|
|
}
|
|
|
|
if (oldMember is Player oldPlayer && oldPlayer.Attributes is { } oldAttr)
|
|
{
|
|
oldAttr[Stats.NearbyPartyMemberCount] = 0;
|
|
}
|
|
|
|
this._partyMembers = updated;
|
|
}
|
|
|
|
await this.SendPartyListAsync().ConfigureAwait(false);
|
|
await this.UpdateNearbyCountAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replaces the live member with an <see cref="OfflinePartyMember"/> snapshot,
|
|
/// keeping the party slot reserved for reconnection.
|
|
/// </summary>
|
|
/// <param name="member">The member who is leaving temporarily.</param>
|
|
public ValueTask LeaveTemporarilyAsync(IPartyMember member)
|
|
{
|
|
var snapshot = new OfflinePartyMember(member);
|
|
return this.ReplaceMemberAsync(member, snapshot);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Kicks the member at the given index.
|
|
/// </summary>
|
|
/// <param name="index">The party list index of the member to kick.</param>
|
|
public async ValueTask KickPlayerAsync(byte index)
|
|
{
|
|
var toKick = this._partyMembers[index];
|
|
await this.ExitPartyAsync(toKick, index).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Allows a member to kick themselves.
|
|
/// </summary>
|
|
/// <param name="sender">The member who initiated the kick.</param>
|
|
public async ValueTask KickMySelfAsync(IPartyMember sender)
|
|
{
|
|
var index = Array.IndexOf(this._partyMembers, sender);
|
|
if (index >= 0)
|
|
{
|
|
await this.ExitPartyAsync(sender, (byte)index).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a chat message to all party members.
|
|
/// </summary>
|
|
/// <param name="message">The message to send.</param>
|
|
/// <param name="senderCharacterName">The name of the sending character.</param>
|
|
public async ValueTask SendChatMessageAsync(string message, string senderCharacterName)
|
|
{
|
|
foreach (var member in this._partyMembers)
|
|
{
|
|
try
|
|
{
|
|
await member.InvokeViewPlugInAsync<IChatViewPlugIn>(
|
|
p => p.ChatMessageAsync(message, senderCharacterName, ChatMessageType.Party)).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
this._logger.LogDebug(ex, "Error sending chat message to {Name}", member.Name);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Distributes experience to nearby party members after a kill.
|
|
/// </summary>
|
|
/// <param name="killedObject">The object that was killed.</param>
|
|
/// <param name="killer">The killer who is a party member.</param>
|
|
/// <returns>The total experience distributed.</returns>
|
|
public async ValueTask<int> DistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer)
|
|
{
|
|
using var l = await this._distributionLock.LockAsync();
|
|
try
|
|
{
|
|
return await this.InternalDistributeExperienceAfterKillAsync(killedObject, killer).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
this._distributionList.Clear();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Distributes money to nearby party members after a kill.
|
|
/// </summary>
|
|
/// <param name="killed">The object that was killed.</param>
|
|
/// <param name="killer">The killer who is a party member.</param>
|
|
/// <param name="amount">The amount of money to distribute.</param>
|
|
public async ValueTask DistributeMoneyAfterKillAsync(IAttackable killed, IPartyMember killer, uint amount)
|
|
{
|
|
using var l = await this._distributionLock.LockAsync();
|
|
try
|
|
{
|
|
this._logger.LogDebug("Distributing money after killing {name}", killed.GetName());
|
|
this._distributionList.AddRange(
|
|
this._partyMembers.OfType<Player>()
|
|
.Where(p => p.CurrentMap == killer.CurrentMap
|
|
&& !p.IsAtSafezone()
|
|
&& p.Attributes is { }));
|
|
|
|
if (this._distributionList.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var moneyPart = amount / this._distributionList.Count;
|
|
foreach (var player in this._distributionList)
|
|
{
|
|
player.TryAddMoney((int)(moneyPart * player.Attributes![Stats.MoneyAmountRate]));
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
this._distributionList.Clear();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets drop item groups from nearby party members' active quests.
|
|
/// </summary>
|
|
/// <param name="killer">The party member who made the kill.</param>
|
|
/// <returns>A list of drop item groups from nearby party members' active quests.</returns>
|
|
public async ValueTask<IList<DropItemGroup>> GetQuestDropItemGroupsAsync(IPartyMember killer)
|
|
{
|
|
using var l = await this._distributionLock.LockAsync();
|
|
try
|
|
{
|
|
using (await killer.ObserverLock.ReaderLockAsync().ConfigureAwait(false))
|
|
{
|
|
this._distributionList.AddRange(
|
|
this._partyMembers.OfType<Player>()
|
|
.Where(p => p.CurrentMap == killer.CurrentMap
|
|
&& !p.IsAtSafezone()
|
|
&& p.IsAlive
|
|
&& (p == killer || killer.Observers.Contains(p))));
|
|
}
|
|
|
|
if (this._distributionList.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var result = this._distributionList
|
|
.SelectMany(m => m.SelectedCharacter?.GetQuestDropItemGroups() ?? [])
|
|
.ToList();
|
|
|
|
return result.Count == 0 ? [] : result;
|
|
}
|
|
finally
|
|
{
|
|
this._distributionList.Clear();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override async ValueTask DisposeAsyncCore()
|
|
{
|
|
if (this._healthUpdateCts is { } cts)
|
|
{
|
|
await cts.CancelAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
if (this._healthUpdateTask is { } task)
|
|
{
|
|
await task.ConfigureAwait(false);
|
|
}
|
|
|
|
IPartyMember[] members;
|
|
lock (this._writeLock)
|
|
{
|
|
members = this._partyMembers;
|
|
this._partyMembers = [];
|
|
}
|
|
|
|
for (byte i = 0; i < members.Length; i++)
|
|
{
|
|
var member = members[i];
|
|
try
|
|
{
|
|
var index = i;
|
|
await member.InvokeViewPlugInAsync<IPartyMemberRemovedPlugIn>(
|
|
p => p.PartyMemberRemovedAsync(index)).ConfigureAwait(false);
|
|
this.CleanupMember(member);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
this._logger.LogDebug(ex, "Error notifying {Name} of party dissolution", member.Name);
|
|
}
|
|
}
|
|
|
|
PartyCount.Add(-1);
|
|
await base.DisposeAsyncCore().ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
this._healthUpdateCts?.Cancel();
|
|
this._healthUpdateCts?.Dispose();
|
|
this._healthUpdateCts = null;
|
|
}
|
|
|
|
base.Dispose(disposing);
|
|
}
|
|
|
|
private static (int Total, float PerLevel) CalculatePartyExperience(List<Player> recipients, IAttackable killed)
|
|
{
|
|
var memberCount = recipients.Count;
|
|
var totalLevel = recipients.Sum(p => (int)p.Attributes![Stats.TotalLevel]);
|
|
var averageLevel = totalLevel / memberCount;
|
|
var baseExperience = killed.CalculateBaseExperience(averageLevel);
|
|
|
|
var partyBonusMultiplier = Math.Pow(1.05, memberCount - 1);
|
|
var mapExperienceMultiplier = killed.CurrentMap?.Definition.ExpMultiplier ?? 1;
|
|
var totalBaseExperience = baseExperience * memberCount * partyBonusMultiplier * mapExperienceMultiplier;
|
|
|
|
var attributes = recipients[0].Attributes!;
|
|
var randomMinMultiplier = attributes[Stats.RandomExperienceMinMultiplier];
|
|
var randomMaxMultiplier = attributes[Stats.RandomExperienceMaxMultiplier];
|
|
var totalExperience = CalculateTotalExperience(totalBaseExperience, randomMinMultiplier, randomMaxMultiplier);
|
|
var perLevel = (float)totalExperience / totalLevel;
|
|
|
|
return (totalExperience, perLevel);
|
|
}
|
|
|
|
private static int CalculateTotalExperience(double totalBaseExperience, float randomMinMultiplier, float randomMaxMultiplier)
|
|
{
|
|
if (randomMinMultiplier <= 0 || randomMaxMultiplier <= 0)
|
|
{
|
|
return (int)totalBaseExperience;
|
|
}
|
|
|
|
var minimumExperience = (int)(totalBaseExperience * randomMinMultiplier);
|
|
var maximumExperience = (int)(totalBaseExperience * randomMaxMultiplier);
|
|
if (minimumExperience < maximumExperience)
|
|
{
|
|
return Rand.NextInt(minimumExperience, maximumExperience);
|
|
}
|
|
|
|
return (int)totalBaseExperience;
|
|
}
|
|
|
|
private static async ValueTask AwardExperienceAsync(Player player, float perLevel, IAttackable killed)
|
|
{
|
|
var attributes = player.Attributes!;
|
|
var isAtMaxLevel = (short)attributes[Stats.Level] == player.GameContext.Configuration.MaximumLevel;
|
|
var isMasterClass = player.SelectedCharacter?.CharacterClass?.IsMasterClass ?? false;
|
|
|
|
if (isAtMaxLevel && isMasterClass)
|
|
{
|
|
var exp = (int)(perLevel
|
|
* attributes[Stats.TotalLevel]
|
|
* player.GameContext.MasterExperienceRate
|
|
* (attributes[Stats.MasterExperienceRate] + attributes[Stats.BonusExperienceRate]));
|
|
|
|
await player.AddMasterExperienceAsync(exp, killed).ConfigureAwait(false);
|
|
}
|
|
else if (!isAtMaxLevel)
|
|
{
|
|
var exp = (int)(perLevel
|
|
* attributes[Stats.Level]
|
|
* player.GameContext.ExperienceRate
|
|
* (attributes[Stats.ExperienceRate] + attributes[Stats.BonusExperienceRate]));
|
|
|
|
await player.AddExperienceAsync(exp, killed).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
// Player is at max level but has not completed master quest. No experience awarded.
|
|
}
|
|
}
|
|
|
|
private async ValueTask ExitPartyAsync(IPartyMember member, byte index)
|
|
{
|
|
bool shouldDispose;
|
|
lock (this._writeLock)
|
|
{
|
|
if (!this._partyMembers.Contains(member))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var remainingCount = this._partyMembers.Length - 1;
|
|
shouldDispose = remainingCount < 2;
|
|
|
|
if (!shouldDispose)
|
|
{
|
|
this._partyMembers = this._partyMembers.Where(m => m != member).ToArray();
|
|
}
|
|
}
|
|
|
|
if (shouldDispose)
|
|
{
|
|
await this.DisposeAsync().ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
// Notify the member before cleaning up so the index is still valid.
|
|
try
|
|
{
|
|
await member.InvokeViewPlugInAsync<IPartyMemberRemovedPlugIn>(
|
|
p => p.PartyMemberRemovedAsync(index)).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
this._logger.LogDebug(ex, "Error notifying kicked member {Name}", member.Name);
|
|
}
|
|
|
|
this.CleanupMember(member);
|
|
|
|
await this.SendPartyListAsync().ConfigureAwait(false);
|
|
await this.UpdateNearbyCountAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
private void CleanupMember(IPartyMember member)
|
|
{
|
|
member.Party = null;
|
|
this._partyManager.UntrackMembership(member.Name);
|
|
|
|
if (member is Player player && player.Attributes is { } attributes)
|
|
{
|
|
attributes[Stats.NearbyPartyMemberCount] = 0;
|
|
}
|
|
}
|
|
|
|
private async ValueTask<int> InternalDistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer)
|
|
{
|
|
if (killedObject.IsSummonedMonster)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
using (await killer.ObserverLock.ReaderLockAsync().ConfigureAwait(false))
|
|
{
|
|
this._distributionList.AddRange(
|
|
this._partyMembers.OfType<Player>()
|
|
.Where(p => p.Attributes is { }
|
|
&& (p == killer || killer.Observers.Contains(p))));
|
|
}
|
|
|
|
if (this._distributionList.Count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var (total, perLevel) = CalculatePartyExperience(this._distributionList, killedObject);
|
|
|
|
foreach (var player in this._distributionList)
|
|
{
|
|
await AwardExperienceAsync(player, perLevel, killedObject).ConfigureAwait(false);
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
private async ValueTask UpdateNearbyCountAsync()
|
|
{
|
|
foreach (var member in this._partyMembers)
|
|
{
|
|
if (member is not Player player || player.Attributes is not { } attributes)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var l = await player.ObserverLock.ReaderLockAsync().ConfigureAwait(false);
|
|
attributes[Stats.NearbyPartyMemberCount] = this._partyMembers.Count(player.Observers.Contains);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
this._logger.LogDebug(ex, "Error updating {Stat} for {Name}", nameof(Stats.NearbyPartyMemberCount), player.Name);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async ValueTask SendPartyListAsync()
|
|
{
|
|
foreach (var member in this._partyMembers)
|
|
{
|
|
try
|
|
{
|
|
await member.InvokeViewPlugInAsync<IUpdatePartyListPlugIn>(
|
|
p => p.UpdatePartyListAsync()).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
this._logger.LogDebug(ex, "Error sending party list to {Name}", member.Name);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task HealthUpdateLoopAsync(CancellationToken cancellationToken)
|
|
{
|
|
using var timer = new PeriodicTimer(this._healthUpdateInterval);
|
|
try
|
|
{
|
|
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
|
|
{
|
|
try
|
|
{
|
|
foreach (var member in this._partyMembers)
|
|
{
|
|
var plugIn = member.ViewPlugIns.GetPlugIn<IPartyHealthViewPlugIn>();
|
|
if (plugIn?.IsHealthUpdateNeeded() is true)
|
|
{
|
|
await plugIn.UpdatePartyHealthAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
this._logger.LogError(ex, "Unexpected error during health update");
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected during shutdown.
|
|
}
|
|
}
|
|
}
|