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:
@@ -71,7 +71,8 @@ public class PartyBenchmarks
|
||||
[Benchmark]
|
||||
public async ValueTask DistributeMoneyAfterKillAsync()
|
||||
{
|
||||
await _party.DistributeMoneyAfterKillAsync(_killedObject, _killer, 10000);
|
||||
var shares = _players.Select(p => new MoneyShare(p, 10000 / (uint)_players.Count)).ToList();
|
||||
await _party.DistributeMoneyAfterKillAsync(_killedObject, _killer, shares);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
266
tests/MUnique.OpenMU.Tests/MoneyDistributionTest.cs
Normal file
266
tests/MUnique.OpenMU.Tests/MoneyDistributionTest.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
// <copyright file="MoneyDistributionTest.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.Tests;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MUnique.OpenMU.AttributeSystem;
|
||||
using MUnique.OpenMU.GameLogic;
|
||||
using MUnique.OpenMU.GameLogic.Attributes;
|
||||
using MUnique.OpenMU.Pathfinding;
|
||||
using NUnit.Framework;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the distribution of a money drop between the players which earned it.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class MoneyDistributionTest
|
||||
{
|
||||
private static readonly Point DropPosition = new(100, 100);
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the money is split proportionally to the experience each player gained,
|
||||
/// because the money amount is derived from that experience.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task SharesFollowExperienceProportionsAsync()
|
||||
{
|
||||
var (first, second) = await CreateTwoPlayersAsync().ConfigureAwait(false);
|
||||
|
||||
var shares = MoneyDistribution.CreateShares(1000, [new ExperienceShare(first, 800), new ExperienceShare(second, 200)]);
|
||||
|
||||
Assert.That(shares.Count, Is.EqualTo(2));
|
||||
Assert.That(shares[0].Amount, Is.EqualTo(800u));
|
||||
Assert.That(shares[1].Amount, Is.EqualTo(200u));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the remainder of the integer division is not lost, so the shares always add up
|
||||
/// to the dropped amount.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task SharesAddUpToTheDroppedAmountAsync()
|
||||
{
|
||||
var (first, second) = await CreateTwoPlayersAsync().ConfigureAwait(false);
|
||||
var third = await PlayerTestHelper.CreatePlayerAsync(first.GameContext).ConfigureAwait(false);
|
||||
|
||||
var shares = MoneyDistribution.CreateShares(10, [new ExperienceShare(first, 1), new ExperienceShare(second, 1), new ExperienceShare(third, 1)]);
|
||||
|
||||
Assert.That(shares.Sum(s => (long)s.Amount), Is.EqualTo(10));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the units lost to the integer division are spread over the shares which were cut
|
||||
/// the most, instead of always landing on the same player - which would favour them over many kills.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task RemainderIsSpreadInsteadOfAlwaysGoingToTheSamePlayerAsync()
|
||||
{
|
||||
var (first, second) = await CreateTwoPlayersAsync().ConfigureAwait(false);
|
||||
var third = await PlayerTestHelper.CreatePlayerAsync(first.GameContext).ConfigureAwait(false);
|
||||
|
||||
// 52 split between three equal contributors: 17 each leaves a remainder of 1.
|
||||
var shares = MoneyDistribution.CreateShares(52, [new ExperienceShare(first, 9), new ExperienceShare(second, 9), new ExperienceShare(third, 9)]);
|
||||
|
||||
Assert.That(shares.Sum(s => (long)s.Amount), Is.EqualTo(52));
|
||||
Assert.That(shares.Max(s => s.Amount) - shares.Min(s => s.Amount), Is.LessThanOrEqualTo(1u));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that a player who gained no experience from the kill gets no money from it.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task PlayerWithoutExperienceGetsNoShareAsync()
|
||||
{
|
||||
var (first, second) = await CreateTwoPlayersAsync().ConfigureAwait(false);
|
||||
|
||||
var shares = MoneyDistribution.CreateShares(8, [new ExperienceShare(first, 0), new ExperienceShare(second, 1)]);
|
||||
|
||||
Assert.That(shares[0].Amount, Is.EqualTo(0u));
|
||||
Assert.That(shares[1].Amount, Is.EqualTo(8u));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that a player without a party gets the money multiplied by their money rate.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task SoloPickUpAppliesMoneyRateAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
player.GameContext.Configuration.MaximumInventoryMoney = int.MaxValue;
|
||||
player.Money = 0;
|
||||
SetMoneyRate(player, 2.0f);
|
||||
|
||||
var money = new DroppedMoney(100, DropPosition, player.CurrentMap!);
|
||||
|
||||
Assert.That(await money.TryPickUpByAsync(player).ConfigureAwait(false), Is.True);
|
||||
Assert.That(player.Money, Is.EqualTo(200));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the pick up clamp is calculated on the amount *after* the money rate was applied.
|
||||
/// Clamping the raw amount would let the player exceed the maximum inventory money.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task SoloPickUpClampsAfterApplyingMoneyRateAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
player.GameContext.Configuration.MaximumInventoryMoney = 150;
|
||||
player.GameContext.Configuration.ClampMoneyOnPickup = true;
|
||||
player.Money = 0;
|
||||
SetMoneyRate(player, 2.0f);
|
||||
|
||||
var money = new DroppedMoney(100, DropPosition, player.CurrentMap!);
|
||||
|
||||
Assert.That(await money.TryPickUpByAsync(player).ConfigureAwait(false), Is.True);
|
||||
Assert.That(player.Money, Is.EqualTo(150));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that every party member receives exactly the share which was reserved for them.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task PartyPickUpPaysReservedSharesAsync()
|
||||
{
|
||||
var (first, second) = await CreateTwoPlayersAsync().ConfigureAwait(false);
|
||||
await CreatePartyAsync(first, second).ConfigureAwait(false);
|
||||
|
||||
var money = new DroppedMoney(1000, DropPosition, first.CurrentMap!, [new MoneyShare(first, 800), new MoneyShare(second, 200)]);
|
||||
|
||||
Assert.That(await money.TryPickUpByAsync(first).ConfigureAwait(false), Is.True);
|
||||
Assert.That(first.Money, Is.EqualTo(800));
|
||||
Assert.That(second.Money, Is.EqualTo(200));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the money rate of a party member is applied exactly once, on their own share.
|
||||
/// It used to be applied twice: once for the killer when the drop was created, and once for
|
||||
/// the receiver when it was picked up.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task PartyPickUpAppliesEachMembersOwnRateOnceAsync()
|
||||
{
|
||||
var (first, second) = await CreateTwoPlayersAsync().ConfigureAwait(false);
|
||||
await CreatePartyAsync(first, second).ConfigureAwait(false);
|
||||
SetMoneyRate(first, 2.0f);
|
||||
|
||||
var money = new DroppedMoney(200, DropPosition, first.CurrentMap!, [new MoneyShare(first, 100), new MoneyShare(second, 100)]);
|
||||
|
||||
Assert.That(await money.TryPickUpByAsync(first).ConfigureAwait(false), Is.True);
|
||||
Assert.That(first.Money, Is.EqualTo(200));
|
||||
Assert.That(second.Money, Is.EqualTo(100));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the share of a player who is not eligible anymore - because they left the party,
|
||||
/// logged out or went to another map - is given to the remaining members instead of being lost.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task PartyPickUpRedistributesShareOfIneligiblePlayerAsync()
|
||||
{
|
||||
var (first, second) = await CreateTwoPlayersAsync().ConfigureAwait(false);
|
||||
await CreatePartyAsync(first).ConfigureAwait(false);
|
||||
|
||||
// 'second' never joined the party, so it can't receive its share.
|
||||
var money = new DroppedMoney(200, DropPosition, first.CurrentMap!, [new MoneyShare(first, 100), new MoneyShare(second, 100)]);
|
||||
|
||||
Assert.That(await money.TryPickUpByAsync(first).ConfigureAwait(false), Is.True);
|
||||
Assert.That(first.Money, Is.EqualTo(200));
|
||||
Assert.That(second.Money, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the pick up clamp is honoured for each party member separately. A member at the
|
||||
/// money limit used to silently lose their share, because the clamp was only applied for solo pick ups.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task PartyPickUpHonoursClampPerMemberAsync()
|
||||
{
|
||||
var (first, second) = await CreateTwoPlayersAsync().ConfigureAwait(false);
|
||||
await CreatePartyAsync(first, second).ConfigureAwait(false);
|
||||
first.GameContext.Configuration.MaximumInventoryMoney = 150;
|
||||
first.GameContext.Configuration.ClampMoneyOnPickup = true;
|
||||
first.Money = 100;
|
||||
second.Money = 0;
|
||||
|
||||
var money = new DroppedMoney(200, DropPosition, first.CurrentMap!, [new MoneyShare(first, 100), new MoneyShare(second, 100)]);
|
||||
|
||||
Assert.That(await money.TryPickUpByAsync(first).ConfigureAwait(false), Is.True);
|
||||
Assert.That(first.Money, Is.EqualTo(150));
|
||||
Assert.That(second.Money, Is.EqualTo(100));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that money without reserved shares - for example the fixed amount of an item box -
|
||||
/// is still split equally between the party members.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task MoneyWithoutSharesIsSplitEquallyAsync()
|
||||
{
|
||||
var (first, second) = await CreateTwoPlayersAsync().ConfigureAwait(false);
|
||||
await CreatePartyAsync(first, second).ConfigureAwait(false);
|
||||
|
||||
var money = new DroppedMoney(200, DropPosition, first.CurrentMap!);
|
||||
|
||||
Assert.That(await money.TryPickUpByAsync(first).ConfigureAwait(false), Is.True);
|
||||
Assert.That(first.Money, Is.EqualTo(100));
|
||||
Assert.That(second.Money, Is.EqualTo(100));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the drop is not consumed when no party member could take anything, so it stays
|
||||
/// available instead of being lost.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task PartyPickUpKeepsMoneyAvailableWhenNobodyCanTakeItAsync()
|
||||
{
|
||||
var (first, second) = await CreateTwoPlayersAsync().ConfigureAwait(false);
|
||||
await CreatePartyAsync(first, second).ConfigureAwait(false);
|
||||
first.GameContext.Configuration.MaximumInventoryMoney = 100;
|
||||
first.Money = 100;
|
||||
second.Money = 100;
|
||||
|
||||
var money = new DroppedMoney(200, DropPosition, first.CurrentMap!, [new MoneyShare(first, 100), new MoneyShare(second, 100)]);
|
||||
|
||||
Assert.That(await money.TryPickUpByAsync(first).ConfigureAwait(false), Is.False);
|
||||
Assert.That(first.Money, Is.EqualTo(100));
|
||||
Assert.That(second.Money, Is.EqualTo(100));
|
||||
}
|
||||
|
||||
private static void SetMoneyRate(Player player, float rate)
|
||||
{
|
||||
player.Attributes!.AddElement(new SimpleElement(rate, AggregateType.Multiplicate), Stats.MoneyAmountRate);
|
||||
Assert.That(
|
||||
player.Attributes[Stats.MoneyAmountRate],
|
||||
Is.EqualTo(rate).Within(0.001f),
|
||||
"test setup: the money rate was not applied as expected");
|
||||
}
|
||||
|
||||
private static async ValueTask<Party> CreatePartyAsync(params Player[] members)
|
||||
{
|
||||
var party = new Party(new PartyManager(5, new NullLogger<Party>()), 5, new NullLogger<Party>());
|
||||
foreach (var member in members)
|
||||
{
|
||||
await party.AddAsync(member).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return party;
|
||||
}
|
||||
|
||||
private static async ValueTask<(Player First, Player Second)> CreateTwoPlayersAsync()
|
||||
{
|
||||
var first = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
first.GameContext.Configuration.MaximumInventoryMoney = int.MaxValue;
|
||||
first.Money = 0;
|
||||
|
||||
var second = await PlayerTestHelper.CreatePlayerAsync(first.GameContext).ConfigureAwait(false);
|
||||
second.Money = 0;
|
||||
|
||||
Assert.That(first.CurrentMap, Is.Not.Null, "test setup: the players need a map");
|
||||
Assert.That(second.CurrentMap, Is.SameAs(first.CurrentMap), "test setup: the players need to be on the same map");
|
||||
Assert.That(first.IsAtSafezone(), Is.False, "test setup: the players must not be at a safezone");
|
||||
|
||||
return (first, second);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user