Derive each player's zen from the experience they actually gained

The money amount of a monster drop is computed as "gained experience + 7", but
in a party it was neither derived from, nor distributed like, that experience.

DistributeExperienceAfterKillAsync returned a party total without the game rate
and the personal experience rates, while AddExpAfterKillAsync returned a value
which had them applied. Both landed in the same variable in OnDeathAsync, so the
party number was orders of magnitude smaller. That mismatch was worked around by
recalculating the killer's solo experience for money purposes, which pinned the
party pool to a solo-sized amount: with the pool then split by member count, a
party of three received about a third of the solo zen each, while its experience
pool grows with the party size.

AwardExperienceAsync already computes the per member experience with all rates
applied, it just discarded it. It now returns that value, the distribution
returns the per member breakdown, and the money is split proportionally to it.
The workaround is gone, the pool follows the experience, and each member's zen
matches their own level, their own rates and their own master/normal branch. The
units lost to the integer division are handed to the shares which were cut the
most, one each, so no member is systematically favoured over many kills.

Two defects around the money rate are fixed on the way:

- MoneyAmountRate was applied for the killer when the drop was created and again
  for the receiver when a party picked it up, so a rate of 3.0 paid a party 9x.
  On the "money straight into the inventory" path for a solo killer it was not
  applied at all. It is now applied exactly once, for the receiver.
- ClampMoneyOnPickup was only honoured for solo pick ups. A party member at
  MaximumInventoryMoney silently lost their share, because the drop was consumed
  as soon as any other member could take one. The clamp now runs on the shared
  payout path, after the money rate, so it clamps the amount actually credited.

Money without a per player breakdown, such as the fixed amount of an item box,
keeps being split equally. Shares of players who are no longer eligible are
redistributed among the remaining ones instead of being lost.

(cherry picked from commit 53f25aca08e690255571f2bb6c796bb7c10934a2)
This commit is contained in:
nolt
2026-07-24 11:27:17 +02:00
committed by Acentech Dev
parent 9e0bcede2f
commit 6307ecfc24
8 changed files with 597 additions and 104 deletions

View File

@@ -201,8 +201,8 @@ public sealed class Party : AsyncDisposable
/// </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)
/// <returns>The experience which each party member gained, with all experience rates applied.</returns>
public async ValueTask<IReadOnlyList<ExperienceShare>> DistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer)
{
using var l = await this._distributionLock.LockAsync();
try
@@ -220,34 +220,29 @@ public sealed class Party : AsyncDisposable
/// </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)
/// <param name="shares">The part of the money which is reserved for each party member.</param>
public ValueTask DistributeMoneyAfterKillAsync(IAttackable killed, IPartyMember killer, IReadOnlyList<MoneyShare> shares)
{
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 { }));
// No lock is taken here: unlike the experience distribution this no longer touches the shared
// _distributionList, and paying out the pre-computed shares is consistent with the lock-free
// pick up path in DroppedMoney.
this._logger.LogDebug("Distributing money after killing {name}", killed.GetName());
_ = MoneyDistribution.TryPayShares(shares, player => this.IsEligibleForMoney(player, killer));
return ValueTask.CompletedTask;
}
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>
/// Determines whether the player may receive a part of a money drop of the party.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="killer">The killer who is a party member.</param>
/// <returns><c>True</c>, if the player may receive money; Otherwise, <c>false</c>.</returns>
internal bool IsEligibleForMoney(Player player, IPartyMember killer)
{
return this._partyMembers.Contains(player)
&& player.CurrentMap == killer.CurrentMap
&& !player.IsAtSafezone()
&& player.Attributes is { };
}
/// <summary>
@@ -340,7 +335,7 @@ public sealed class Party : AsyncDisposable
base.Dispose(disposing);
}
private static (int Total, float PerLevel) CalculatePartyExperience(List<Player> recipients, IAttackable killed)
private static float CalculatePartyExperiencePerLevel(List<Player> recipients, IAttackable killed)
{
var memberCount = recipients.Count;
var totalLevel = recipients.Sum(p => (int)p.Attributes![Stats.TotalLevel]);
@@ -355,9 +350,8 @@ public sealed class Party : AsyncDisposable
var randomMinMultiplier = attributes[Stats.RandomExperienceMinMultiplier];
var randomMaxMultiplier = attributes[Stats.RandomExperienceMaxMultiplier];
var totalExperience = CalculateTotalExperience(totalBaseExperience, randomMinMultiplier, randomMaxMultiplier);
var perLevel = (float)totalExperience / totalLevel;
return (totalExperience, perLevel);
return (float)totalExperience / totalLevel;
}
private static int CalculateTotalExperience(double totalBaseExperience, float randomMinMultiplier, float randomMaxMultiplier)
@@ -377,7 +371,7 @@ public sealed class Party : AsyncDisposable
return (int)totalBaseExperience;
}
private static async ValueTask AwardExperienceAsync(Player player, float perLevel, IAttackable killed)
private static async ValueTask<int> AwardExperienceAsync(Player player, float perLevel, IAttackable killed)
{
var attributes = player.Attributes!;
var isAtMaxLevel = (short)attributes[Stats.Level] == player.GameContext.Configuration.MaximumLevel;
@@ -391,8 +385,10 @@ public sealed class Party : AsyncDisposable
* (attributes[Stats.MasterExperienceRate] + attributes[Stats.BonusExperienceRate]));
await player.AddMasterExperienceAsync(exp, killed).ConfigureAwait(false);
return exp;
}
else if (!isAtMaxLevel)
if (!isAtMaxLevel)
{
var exp = (int)(perLevel
* attributes[Stats.Level]
@@ -400,11 +396,11 @@ public sealed class Party : AsyncDisposable
* (attributes[Stats.ExperienceRate] + attributes[Stats.BonusExperienceRate]));
await player.AddExperienceAsync(exp, killed).ConfigureAwait(false);
return exp;
}
else
{
// Player is at max level but has not completed master quest. No experience awarded.
}
// Player is at max level but has not completed master quest. No experience awarded.
return 0;
}
private async ValueTask ExitPartyAsync(IPartyMember member, byte index)
@@ -466,11 +462,11 @@ public sealed class Party : AsyncDisposable
}
}
private async ValueTask<int> InternalDistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer)
private async ValueTask<IReadOnlyList<ExperienceShare>> InternalDistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer)
{
if (killedObject.IsSummonedMonster)
{
return 0;
return [];
}
using (await killer.ObserverLock.ReaderLockAsync().ConfigureAwait(false))
@@ -483,17 +479,20 @@ public sealed class Party : AsyncDisposable
if (this._distributionList.Count == 0)
{
return 0;
return [];
}
var (total, perLevel) = CalculatePartyExperience(this._distributionList, killedObject);
var perLevel = CalculatePartyExperiencePerLevel(this._distributionList, killedObject);
// The shares are copied into their own list, because _distributionList is reused and cleared by the caller.
var shares = new List<ExperienceShare>(this._distributionList.Count);
foreach (var player in this._distributionList)
{
await AwardExperienceAsync(player, perLevel, killedObject).ConfigureAwait(false);
var experience = await AwardExperienceAsync(player, perLevel, killedObject).ConfigureAwait(false);
shares.Add(new ExperienceShare(player, experience));
}
return total;
return shares;
}
private async ValueTask UpdateNearbyCountAsync()