diff --git a/src/GameLogic/DroppedMoney.cs b/src/GameLogic/DroppedMoney.cs index f3372c0..8973353 100644 --- a/src/GameLogic/DroppedMoney.cs +++ b/src/GameLogic/DroppedMoney.cs @@ -6,7 +6,6 @@ namespace MUnique.OpenMU.GameLogic; using System.Diagnostics; using System.Threading; -using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.Pathfinding; using Nito.AsyncEx; @@ -20,6 +19,8 @@ public sealed class DroppedMoney : AsyncDisposable, ILocateable /// private readonly AsyncLock _pickupLock; + private readonly IReadOnlyList _shares; + private Timer? _removeTimer; private bool _availableToPick = true; @@ -30,9 +31,14 @@ public sealed class DroppedMoney : AsyncDisposable, ILocateable /// The amount. /// The position where the item was dropped on the map. /// The map. - public DroppedMoney(uint amount, Point position, GameMap map) + /// + /// The part of the money which is reserved for each player, matching the experience they gained from the kill. + /// When it's empty - for example for money from an item box - the money is split equally instead. + /// + public DroppedMoney(uint amount, Point position, GameMap map, IReadOnlyList? shares = null) { this.Amount = amount; + this._shares = shares ?? []; this._pickupLock = new(); this.Position = position; this.CurrentMap = map; @@ -131,54 +137,28 @@ public sealed class DroppedMoney : AsyncDisposable, ILocateable /// True, if at least one player received money; Otherwise, false. private bool TryGiveMoneyTo(Player player) { - if (player.Party is { } party) + if (player.Party is not { } party) { - var partyMembers = party.PartyList - .OfType() - .Where(p => p.CurrentMap == player.CurrentMap && !p.IsAtSafezone() && p.Attributes is { }) - .ToList(); - - if (partyMembers.Count == 0) + if (!MoneyDistribution.TryPay(player, this.Amount)) { - player.Logger.LogDebug("No party member could receive the money, Player {0}, Money {1}", player, this); + player.Logger.LogDebug("Money could not be added to the inventory, Player {0}, Money {1}", player, this); return false; } - var share = (int)(this.Amount / partyMembers.Count); - var received = false; - foreach (var member in partyMembers) - { - received |= member.TryAddMoney((int)(share * member.Attributes![Stats.MoneyAmountRate])); - } - - if (!received) - { - player.Logger.LogDebug("No party member could take the money, Player {0}, Money {1}", player, this); - } - - return received; + return true; } - var amountToAdd = (int)this.Amount; - if (player.GameContext?.Configuration?.ClampMoneyOnPickup ?? false) + var shares = this._shares.Count > 0 + ? this._shares + : MoneyDistribution.CreateEqualShares(this.Amount, party.PartyList.OfType().ToList()); + + var received = MoneyDistribution.TryPayShares(shares, member => party.IsEligibleForMoney(member, player)); + if (!received) { - var maxMoney = player.GameContext?.Configuration?.MaximumInventoryMoney ?? int.MaxValue; - amountToAdd = (int)Math.Min(this.Amount, (uint)Math.Max(0, maxMoney - player.Money)); - - if (amountToAdd <= 0) - { - player.Logger.LogDebug("Player is at maximum money limit, Player {0}, Money {1}", player, this); - return false; - } + player.Logger.LogDebug("No party member could take the money, Player {0}, Money {1}", player, this); } - if (!player.TryAddMoney(amountToAdd)) - { - player.Logger.LogDebug("Money could not be added to the inventory, Player {0}, Money {1}", player, this); - return false; - } - - return true; + return received; } [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")] diff --git a/src/GameLogic/ExperienceShare.cs b/src/GameLogic/ExperienceShare.cs new file mode 100644 index 0000000..7e731a8 --- /dev/null +++ b/src/GameLogic/ExperienceShare.cs @@ -0,0 +1,12 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +/// +/// The experience which a single player gained from a kill. +/// +/// The player which gained the experience. +/// The gained experience, with all experience rates already applied. +public readonly record struct ExperienceShare(Player Player, int Experience); diff --git a/src/GameLogic/MoneyDistribution.cs b/src/GameLogic/MoneyDistribution.cs new file mode 100644 index 0000000..0dbe24c --- /dev/null +++ b/src/GameLogic/MoneyDistribution.cs @@ -0,0 +1,223 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +using MUnique.OpenMU.GameLogic.Attributes; + +/// +/// Splits a money drop between players and hands it over to them. +/// +/// +/// This is the single place where and +/// are applied, so that +/// every path - dropped on the ground, added directly, solo or in a party - treats them the same way. +/// +internal static class MoneyDistribution +{ + /// + /// Splits the money proportionally to the experience each player gained from the kill. + /// + /// The total amount of money to split. + /// The experience gained per player. + /// The part of the money which is reserved for each player. + public static IReadOnlyList CreateShares(uint amount, IReadOnlyList experienceShares) + { + if (experienceShares.Count == 0) + { + return []; + } + + if (experienceShares.Count == 1) + { + return [new MoneyShare(experienceShares[0].Player, amount)]; + } + + var weights = new long[experienceShares.Count]; + for (int i = 0; i < experienceShares.Count; i++) + { + weights[i] = experienceShares[i].Experience; + } + + var parts = SplitByWeight(amount, weights); + var shares = new MoneyShare[experienceShares.Count]; + for (int i = 0; i < experienceShares.Count; i++) + { + shares[i] = new MoneyShare(experienceShares[i].Player, parts[i]); + } + + return shares; + } + + /// + /// Splits the money equally, which is used when no per player experience is known - for example + /// for the fixed money amount of an item box. + /// + /// The total amount of money to split. + /// The players to split it between. + /// The part of the money which is reserved for each player. + public static IReadOnlyList CreateEqualShares(uint amount, IReadOnlyList players) + { + if (players.Count == 0) + { + return []; + } + + var parts = SplitByWeight(amount, new long[players.Count]); + var shares = new MoneyShare[players.Count]; + for (int i = 0; i < players.Count; i++) + { + shares[i] = new MoneyShare(players[i], parts[i]); + } + + return shares; + } + + /// + /// Hands the shares over to the players which are still eligible. The shares of players which + /// are not eligible anymore are re-distributed between the remaining ones. + /// + /// The shares. + /// Determines whether a player may still receive their share. + /// True, if at least one player received money; Otherwise, false. + public static bool TryPayShares(IReadOnlyList shares, Func isEligible) + { + var eligible = new List(shares.Count); + uint forfeited = 0; + foreach (var share in shares) + { + if (isEligible(share.Player)) + { + eligible.Add(share); + } + else + { + forfeited += share.Amount; + } + } + + if (eligible.Count == 0) + { + return false; + } + + var extra = new uint[eligible.Count]; + if (forfeited > 0) + { + var weights = new long[eligible.Count]; + for (int i = 0; i < eligible.Count; i++) + { + weights[i] = eligible[i].Amount; + } + + extra = SplitByWeight(forfeited, weights); + } + + var received = false; + for (int i = 0; i < eligible.Count; i++) + { + received |= TryPay(eligible[i].Player, eligible[i].Amount + extra[i]); + } + + return received; + } + + /// + /// Adds the money to the inventory of the player, applying their + /// and the configured pick up clamp. + /// + /// The player which should receive the money. + /// The amount, before the money rate of the player is applied. + /// True, if the player received money; Otherwise, false. + public static bool TryPay(Player player, uint amount) + { + if (amount == 0) + { + return false; + } + + // The rate is applied in double precision: a float multiplication would round money amounts + // above the ~16.7M the float mantissa can represent exactly, before the cast to long. + var scaled = (long)(amount * (double)(player.Attributes?[Stats.MoneyAmountRate] ?? 1.0f)); + if (scaled <= 0) + { + return false; + } + + var amountToAdd = (int)Math.Min(scaled, int.MaxValue); + if (player.GameContext?.Configuration?.ClampMoneyOnPickup ?? false) + { + var maximumMoney = player.GameContext?.Configuration?.MaximumInventoryMoney ?? int.MaxValue; + amountToAdd = (int)Math.Min(amountToAdd, Math.Max(0, maximumMoney - player.Money)); + if (amountToAdd <= 0) + { + return false; + } + } + + return player.TryAddMoney(amountToAdd); + } + + /// + /// Splits an amount proportionally to the given weights. When all weights are zero, it's split equally. + /// + private static uint[] SplitByWeight(uint amount, IReadOnlyList weights) + { + var result = new uint[weights.Count]; + if (weights.Count == 0 || amount == 0) + { + return result; + } + + long totalWeight = 0; + foreach (var weight in weights) + { + totalWeight += Math.Max(0, weight); + } + + var remainders = new long[weights.Count]; + uint distributed = 0; + for (int i = 0; i < weights.Count; i++) + { + if (totalWeight > 0) + { + var numerator = (long)amount * Math.Max(0, weights[i]); + result[i] = (uint)(numerator / totalWeight); + remainders[i] = numerator % totalWeight; + } + else + { + result[i] = amount / (uint)weights.Count; + remainders[i] = 1; + } + + distributed += result[i]; + } + + // The integer division loses up to one unit per share. Handing all of it to the same + // share would systematically favour one player over many kills, so the units go to the + // shares which were cut the most, one each (largest remainder method). + for (var rest = amount - distributed; rest > 0; rest--) + { + var pick = -1; + for (int i = 0; i < remainders.Length; i++) + { + if (remainders[i] > 0 && (pick < 0 || remainders[i] > remainders[pick])) + { + pick = i; + } + } + + if (pick < 0) + { + break; + } + + result[pick]++; + remainders[pick] = 0; + } + + return result; + } +} diff --git a/src/GameLogic/MoneyShare.cs b/src/GameLogic/MoneyShare.cs new file mode 100644 index 0000000..7bc99db --- /dev/null +++ b/src/GameLogic/MoneyShare.cs @@ -0,0 +1,12 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +/// +/// The part of a money drop which is reserved for a single player. +/// +/// The player for which the part is reserved. +/// The amount of money, before the of the player is applied. +public readonly record struct MoneyShare(Player Player, uint Amount); diff --git a/src/GameLogic/NPC/AttackableNpcBase.cs b/src/GameLogic/NPC/AttackableNpcBase.cs index 79662dc..2a17527 100644 --- a/src/GameLogic/NPC/AttackableNpcBase.cs +++ b/src/GameLogic/NPC/AttackableNpcBase.cs @@ -323,7 +323,9 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable var player = this.GetHitNotificationTarget(attacker); if (player is { }) { - int exp = await (player.Party?.DistributeExperienceAfterKillAsync(this, player) ?? player.AddExpAfterKillAsync(this)).ConfigureAwait(false); + var experienceShares = player.Party is { } party + ? await party.DistributeExperienceAfterKillAsync(this, player).ConfigureAwait(false) + : [new ExperienceShare(player, await player.AddExpAfterKillAsync(this).ConfigureAwait(false))]; if (attacker == player) { await player.AfterKilledMonsterAsync().ConfigureAwait(false); @@ -341,7 +343,7 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable selectedCharacter.StateRemainingSeconds -= (int)this.Attributes[Stats.Level]; } - _ = this.DropItemDelayedAsync(player, exp); // don't wait for completion. + _ = this.DropItemDelayedAsync(player, experienceShares); // don't wait for completion. } } } @@ -401,46 +403,44 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable } } - private async ValueTask HandleMoneyDropAsync(uint amount, Player killer) + private async ValueTask HandleMoneyDropAsync(uint amount, Player killer, IReadOnlyList experienceShares) { + // Each player gets the part of the money which matches the experience they gained from the kill, + // so that money follows the same distribution as the experience it is derived from. + var shares = MoneyDistribution.CreateShares(amount, experienceShares); + // We don't drop money in Devil Square, etc. var shouldDropMoney = killer.GameContext.Configuration.ShouldDropMoney && killer.CurrentMiniGame is null; if (!shouldDropMoney) { - var party = killer.Party; - if (party is null) + if (killer.Party is { } party) { - killer.TryAddMoney((int)amount); + await party.DistributeMoneyAfterKillAsync(this, killer, shares).ConfigureAwait(false); } else { - await party.DistributeMoneyAfterKillAsync(this, killer, amount).ConfigureAwait(false); + _ = MoneyDistribution.TryPay(killer, amount); } return; } - var droppedMoney = new DroppedMoney((uint)(amount * (killer.Attributes?[Stats.MoneyAmountRate] ?? 1.0f)), this.Position, this.CurrentMap); + var droppedMoney = new DroppedMoney(amount, this.Position, this.CurrentMap, shares); await this.CurrentMap.AddAsync(droppedMoney).ConfigureAwait(false); } - private async ValueTask DropItemAsync(int exp, Player killer) + private async ValueTask DropItemAsync(IReadOnlyList experienceShares, Player killer) { - // When the killer is in a party, DistributeExperienceAfterKillAsync returns a - // total party experience that does NOT include game rate (ExperienceRate) or - // personal experience rate multipliers. Since the money drop amount is - // derived from this experience value, party money drops were dramatically - // lower than solo drops. We recalculate the experience for money purposes - // using the solo formula so money is consistent regardless of party state. - if (killer.Party is not null) + var exp = 0; + foreach (var share in experienceShares) { - exp = killer.CalculateExpAfterKill(this); + exp += share.Experience; } var (generatedItems, droppedMoney) = await this._dropGenerator.GenerateItemDropsAsync(this.Definition, exp, killer).ConfigureAwait(false); if (droppedMoney > 0) { - await this.HandleMoneyDropAsync(droppedMoney.Value, killer).ConfigureAwait(false); + await this.HandleMoneyDropAsync(droppedMoney.Value, killer, experienceShares).ConfigureAwait(false); } var firstItem = !droppedMoney.HasValue; @@ -463,12 +463,12 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable } } - private async ValueTask DropItemDelayedAsync(Player player, int gainedExp) + private async ValueTask DropItemDelayedAsync(Player player, IReadOnlyList experienceShares) { try { await Task.Delay(1000).ConfigureAwait(false); - await this.DropItemAsync(gainedExp, player).ConfigureAwait(false); + await this.DropItemAsync(experienceShares, player).ConfigureAwait(false); } catch (Exception ex) { diff --git a/src/GameLogic/Party.cs b/src/GameLogic/Party.cs index c30388c..2bc71a5 100644 --- a/src/GameLogic/Party.cs +++ b/src/GameLogic/Party.cs @@ -201,8 +201,8 @@ public sealed class Party : AsyncDisposable /// /// The object that was killed. /// The killer who is a party member. - /// The total experience distributed. - public async ValueTask DistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer) + /// The experience which each party member gained, with all experience rates applied. + public async ValueTask> DistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer) { using var l = await this._distributionLock.LockAsync(); try @@ -220,34 +220,29 @@ public sealed class Party : AsyncDisposable /// /// The object that was killed. /// The killer who is a party member. - /// The amount of money to distribute. - public async ValueTask DistributeMoneyAfterKillAsync(IAttackable killed, IPartyMember killer, uint amount) + /// The part of the money which is reserved for each party member. + public ValueTask DistributeMoneyAfterKillAsync(IAttackable killed, IPartyMember killer, IReadOnlyList 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() - .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(); - } + /// + /// Determines whether the player may receive a part of a money drop of the party. + /// + /// The player. + /// The killer who is a party member. + /// True, if the player may receive money; Otherwise, false. + internal bool IsEligibleForMoney(Player player, IPartyMember killer) + { + return this._partyMembers.Contains(player) + && player.CurrentMap == killer.CurrentMap + && !player.IsAtSafezone() + && player.Attributes is { }; } /// @@ -340,7 +335,7 @@ public sealed class Party : AsyncDisposable base.Dispose(disposing); } - private static (int Total, float PerLevel) CalculatePartyExperience(List recipients, IAttackable killed) + private static float CalculatePartyExperiencePerLevel(List 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 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 InternalDistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer) + private async ValueTask> 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(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() diff --git a/tests/MUnique.OpenMU.GameLogic.Benchmarks/PartyBenchmarks.cs b/tests/MUnique.OpenMU.GameLogic.Benchmarks/PartyBenchmarks.cs index b5a0cb8..cfd312b 100644 --- a/tests/MUnique.OpenMU.GameLogic.Benchmarks/PartyBenchmarks.cs +++ b/tests/MUnique.OpenMU.GameLogic.Benchmarks/PartyBenchmarks.cs @@ -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); } /// diff --git a/tests/MUnique.OpenMU.Tests/MoneyDistributionTest.cs b/tests/MUnique.OpenMU.Tests/MoneyDistributionTest.cs new file mode 100644 index 0000000..ae1fccf --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/MoneyDistributionTest.cs @@ -0,0 +1,266 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +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; + +/// +/// Tests for the distribution of a money drop between the players which earned it. +/// +[TestFixture] +public class MoneyDistributionTest +{ + private static readonly Point DropPosition = new(100, 100); + + /// + /// Tests that the money is split proportionally to the experience each player gained, + /// because the money amount is derived from that experience. + /// + [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)); + } + + /// + /// Tests that the remainder of the integer division is not lost, so the shares always add up + /// to the dropped amount. + /// + [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)); + } + + /// + /// 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. + /// + [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)); + } + + /// + /// Tests that a player who gained no experience from the kill gets no money from it. + /// + [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)); + } + + /// + /// Tests that a player without a party gets the money multiplied by their money rate. + /// + [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)); + } + + /// + /// 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. + /// + [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)); + } + + /// + /// Tests that every party member receives exactly the share which was reserved for them. + /// + [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)); + } + + /// + /// 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. + /// + [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)); + } + + /// + /// 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. + /// + [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)); + } + + /// + /// 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. + /// + [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)); + } + + /// + /// Tests that money without reserved shares - for example the fixed amount of an item box - + /// is still split equally between the party members. + /// + [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)); + } + + /// + /// Tests that the drop is not consumed when no party member could take anything, so it stays + /// available instead of being lost. + /// + [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 CreatePartyAsync(params Player[] members) + { + var party = new Party(new PartyManager(5, new NullLogger()), 5, new NullLogger()); + 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); + } +}