baseline: OpenMU upstream b5a0961 (fresh source)
This commit is contained in:
559
src/GameLogic/Party.cs
Normal file
559
src/GameLogic/Party.cs
Normal file
@@ -0,0 +1,559 @@
|
||||
// <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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user