From 8591bec5251370cb720c5e63a50629efbd0c16b8 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Wed, 11 Nov 2026 23:39:44 +0100 Subject: [PATCH] feat(quests): a quest isn't completed while its rewards don't fit Until now, QuestCompletionAction completed the quest and dropped an item reward which didn't fit into the inventory at the player's feet, where anyone could take it after the owner time; a money reward over the money limit was lost. Now the quest isn't completed while the item rewards don't fit, counting the space of the handed in items as free, or while the money rewards would exceed the money limit. Nothing is taken. The server answers with the quest completion response (F6 0D), whose field IsQuestCompleted becomes the result value which the Season 6 client reads: 0 not completed, 1 completed, 2 zen limit reached, 3 inventory full. For 2 and 3 the client shows its message ("You have reached your Zen limit", "Inventory is full") and disables the button to complete the quest. In the Season 6 data the legacy quests have no item or money rewards (all 338 item and 9 money rewards belong to the newer quests); their response (C1 A2) has no such result, so a refusal sends nothing for them. IStorage.CanAddItems tells if items fit into a storage, including its extensions, after other items were removed, without changing it. It places them in the order of CheckInvSpace, like AddItemAsync does. The packet docs were updated by hand, because the markdown generation needs Node.js. --- ...F6-0D-QuestCompletionResponse_by-server.md | 17 +- src/GameLogic/IStorage.cs | 9 + .../Quests/QuestCompletionAction.cs | 67 +++++-- src/GameLogic/Storage.cs | 90 +++++++++ .../Quest/IQuestCompletionResponsePlugIn.cs | 7 + .../Views/Quest/QuestCompletionRefusal.cs | 21 ++ .../Quest/QuestCompletionResponsePlugIn.cs | 20 +- .../ServerToClient/ConnectionExtensions.cs | 10 +- .../ServerToClient/ServerToClientPackets.cs | 38 +++- .../ServerToClient/ServerToClientPackets.xml | 37 +++- .../ServerToClientPacketsRef.cs | 12 +- .../ServerToClientPacketTests.cs | 4 +- .../QuestRewardSpaceTest.cs | 185 ++++++++++++++++++ 13 files changed, 478 insertions(+), 39 deletions(-) create mode 100644 src/GameLogic/Views/Quest/QuestCompletionRefusal.cs create mode 100644 tests/MUnique.OpenMU.Tests/QuestRewardSpaceTest.cs diff --git a/docs/Packets/C1-F6-0D-QuestCompletionResponse_by-server.md b/docs/Packets/C1-F6-0D-QuestCompletionResponse_by-server.md index 8e0b330ac5..54fc8e38e1 100644 --- a/docs/Packets/C1-F6-0D-QuestCompletionResponse_by-server.md +++ b/docs/Packets/C1-F6-0D-QuestCompletionResponse_by-server.md @@ -2,11 +2,11 @@ ## Is sent when -The server acknowledges the completion of a quest. +The server acknowledges the completion of a quest, or refuses it because the rewards don't fit. ## Causes the following actions on the client side -The client shows the success and possibly requests for the next available quests. +When the quest was completed, the client shows the success and possibly requests for the next available quests. When it was refused, the client shows a message and disables the button to complete the quest. ## Structure @@ -18,4 +18,15 @@ The client shows the success and possibly requests for the next available quests | 3 | 1 | Byte | 0x0D | Packet header - sub packet type identifier | | 4 | 2 | ShortLittleEndian | | QuestNumber | | 6 | 2 | ShortLittleEndian | | QuestGroup | -| 8 | 1 | Boolean | | IsQuestCompleted | \ No newline at end of file +| 8 | 1 | QuestCompletionResult | | Result | + +### QuestCompletionResult Enum + +Defines the result of a quest completion request. + +| Value | Name | Description | +|-------|------|-------------| +| 0 | NotCompleted | The quest wasn't completed. | +| 1 | Completed | The quest was completed. | +| 2 | ZenLimitReached | The quest wasn't completed, because the money of the rewards would exceed the money limit of the inventory. | +| 3 | InventoryFull | The quest wasn't completed, because the item rewards don't fit into the inventory. | \ No newline at end of file diff --git a/src/GameLogic/IStorage.cs b/src/GameLogic/IStorage.cs index 41dcf96d4c..a8d5be6a63 100644 --- a/src/GameLogic/IStorage.cs +++ b/src/GameLogic/IStorage.cs @@ -160,6 +160,15 @@ public interface IStorage /// The index of a slot in which the item would fit. byte? CheckInvSpace(Item item); + /// + /// Determines whether all the items would fit into this storage (including extensions), + /// after the other items were removed, without changing the storage. + /// + /// The items which would be added, in this order, each at the slot which would find. + /// The items of this storage which would be removed before. + /// true, if all the items would fit; otherwise, false. + bool CanAddItems(IEnumerable items, IEnumerable? removedItems = null); + /// /// Checks if the items of another storage will fit into this storage (including extensions), and adds them if possible. /// diff --git a/src/GameLogic/PlayerActions/Quests/QuestCompletionAction.cs b/src/GameLogic/PlayerActions/Quests/QuestCompletionAction.cs index 85cd39d963..eb43a83ca2 100644 --- a/src/GameLogic/PlayerActions/Quests/QuestCompletionAction.cs +++ b/src/GameLogic/PlayerActions/Quests/QuestCompletionAction.cs @@ -74,20 +74,18 @@ public async ValueTask CompleteQuestAsync(Player player, short group, short numb return; } - questState!.LastFinishedQuest = activeQuest; - foreach (var requiredItem in activeQuest.RequiredItems) + var handedInItems = GetHandedInItems(player, activeQuest); + if (GetRefusal(player, activeQuest, handedInItems) is { } refusal) { - var requiredLevel = requiredItem.DropItemGroup?.ItemLevel; - var items = player.Inventory!.Items - .Where(item => Equals(item.Definition, requiredItem.Item) - && (requiredLevel is null || requiredLevel == item.Level)) - .Take(requiredItem.MinimumNumber) - .ToList(); + player.Logger.LogDebug("Refused, {0}.", refusal); + await player.InvokeViewPlugInAsync(p => p.QuestCompletionRefusedAsync(activeQuest, refusal)).ConfigureAwait(false); + return; + } - foreach (var item in items) - { - await player.DestroyInventoryItemAsync(item).ConfigureAwait(false); - } + questState!.LastFinishedQuest = activeQuest; + foreach (var item in handedInItems) + { + await player.DestroyInventoryItemAsync(item).ConfigureAwait(false); } foreach (var reward in activeQuest.Rewards) @@ -99,6 +97,51 @@ public async ValueTask CompleteQuestAsync(Player player, short group, short numb await player.InvokeViewPlugInAsync(p => p.QuestCompletedAsync(activeQuest)).ConfigureAwait(false); } + /// + /// Gets the items of the inventory which are handed in for the required items of the quest. + /// + private static List GetHandedInItems(Player player, QuestDefinition quest) + { + var handedInItems = new List(); + foreach (var requiredItem in quest.RequiredItems) + { + var requiredLevel = requiredItem.DropItemGroup?.ItemLevel; + handedInItems.AddRange(player.Inventory!.Items + .Where(item => Equals(item.Definition, requiredItem.Item) + && (requiredLevel is null || requiredLevel == item.Level)) + .Take(requiredItem.MinimumNumber)); + } + + return handedInItems; + } + + /// + /// Gets the reason why the player can't take the rewards of the quest, or null if the player can take them. + /// The space of the handed in items counts as free, because they are removed before the rewards are added. + /// + private static QuestCompletionRefusal? GetRefusal(Player player, QuestDefinition quest, IReadOnlyCollection handedInItems) + { + var itemRewards = quest.Rewards + .Where(reward => reward.RewardType == QuestRewardType.Item) + .Select(reward => reward.ItemReward) + .OfType() + .ToList(); + if (itemRewards.Count > 0 && player.Inventory?.CanAddItems(itemRewards, handedInItems) is not true) + { + return QuestCompletionRefusal.InventoryFull; + } + + var moneyReward = quest.Rewards + .Where(reward => reward.RewardType == QuestRewardType.Money) + .Sum(reward => (long)reward.Value); + if (moneyReward > 0 && (moneyReward > int.MaxValue || !player.CanAddMoney((int)moneyReward))) + { + return QuestCompletionRefusal.ZenLimitReached; + } + + return null; + } + private static async ValueTask AddRewardAsync(Player player, QuestReward reward, QuestDefinition quest) { switch (reward.RewardType) diff --git a/src/GameLogic/Storage.cs b/src/GameLogic/Storage.cs index 5b2ad29d32..38fb05b3ce 100644 --- a/src/GameLogic/Storage.cs +++ b/src/GameLogic/Storage.cs @@ -218,6 +218,41 @@ public bool TryRemoveMoney(int value) return null; } + /// + public bool CanAddItems(IEnumerable items, IEnumerable? removedItems = null) + { + // The search order is the one of CheckInvSpace: this storage first, then the extensions. + var grids = this.GetStoragesInSearchOrder() + .Select(storage => (Storage: storage, UsedSlots: (bool[,])storage._usedSlots.Clone())) + .ToList(); + + foreach (var removedItem in removedItems ?? []) + { + if (removedItem.Definition is null + || grids.FirstOrDefault(grid => grid.Storage.ContainsSlot(removedItem.ItemSlot)) is not { Storage: { } storage } grid) + { + continue; + } + + var slot = removedItem.ItemSlot - storage._slotOffset; + if (slot >= storage._boxOffset) + { + MarkSlots(grid.UsedSlots, storage.GetRowIndex(slot), storage.GetColumnIndex(slot), removedItem.Definition.Width, removedItem.Definition.Height, false); + } + } + + foreach (var item in items) + { + item.ThrowNotInitializedProperty(item.Definition is null, nameof(item.Definition)); + if (!grids.Any(grid => TryOccupyFirstFreePlace(grid.UsedSlots, item.Definition.Width, item.Definition.Height))) + { + return false; + } + } + + return true; + } + /// public async ValueTask TryTakeAllAsync(IStorage anotherStorage) { @@ -381,6 +416,52 @@ protected bool AddItemInternal(byte slot, Item item) return true; } + private static bool TryOccupyFirstFreePlace(bool[,] usedSlots, byte width, byte height) + { + var rowCount = usedSlots.GetLength(0); + var columnCount = usedSlots.GetLength(1); + for (var row = 0; row + height <= rowCount; row++) + { + for (var column = 0; column + width <= columnCount; column++) + { + if (IsFree(usedSlots, row, column, width, height)) + { + MarkSlots(usedSlots, row, column, width, height, true); + return true; + } + } + } + + return false; + } + + private static bool IsFree(bool[,] usedSlots, int row, int column, byte width, byte height) + { + for (var currentRow = row; currentRow < row + height; currentRow++) + { + for (var currentColumn = column; currentColumn < column + width; currentColumn++) + { + if (usedSlots[currentRow, currentColumn]) + { + return false; + } + } + } + + return true; + } + + private static void MarkSlots(bool[,] usedSlots, int row, int column, byte width, byte height, bool used) + { + for (var currentRow = row; currentRow < row + height && currentRow < usedSlots.GetLength(0); currentRow++) + { + for (var currentColumn = column; currentColumn < column + width && currentColumn < usedSlots.GetLength(1); currentColumn++) + { + usedSlots[currentRow, currentColumn] = used; + } + } + } + private bool AddItem(byte slot, Item item) { var result = this.AddItemInternal((byte)(slot - this._slotOffset), item); @@ -393,6 +474,15 @@ private bool AddItem(byte slot, Item item) return result; } + private IEnumerable GetStoragesInSearchOrder() + { + yield return this; + foreach (var extension in this.Extensions.OfType().SelectMany(extension => extension.GetStoragesInSearchOrder())) + { + yield return extension; + } + } + private byte GetSlot(int column, int row) { byte result = (byte)(this._slotOffset + this._boxOffset + column + (row * InventoryConstants.RowSize)); diff --git a/src/GameLogic/Views/Quest/IQuestCompletionResponsePlugIn.cs b/src/GameLogic/Views/Quest/IQuestCompletionResponsePlugIn.cs index cb16565deb..f4bf759083 100644 --- a/src/GameLogic/Views/Quest/IQuestCompletionResponsePlugIn.cs +++ b/src/GameLogic/Views/Quest/IQuestCompletionResponsePlugIn.cs @@ -22,4 +22,11 @@ public interface IQuestCompletionResponsePlugIn : IViewPlugIn /// The success/state of the quest can be determined by inspecting the player object. /// ValueTask QuestCompletedAsync(QuestDefinition quest); + + /// + /// Shows that the completion of the specified quest was refused, although its requirements are met. + /// + /// The quest. + /// The reason of the refusal. + ValueTask QuestCompletionRefusedAsync(QuestDefinition quest, QuestCompletionRefusal refusal); } \ No newline at end of file diff --git a/src/GameLogic/Views/Quest/QuestCompletionRefusal.cs b/src/GameLogic/Views/Quest/QuestCompletionRefusal.cs new file mode 100644 index 0000000000..836c12e05a --- /dev/null +++ b/src/GameLogic/Views/Quest/QuestCompletionRefusal.cs @@ -0,0 +1,21 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Views.Quest; + +/// +/// The reason why the completion of a quest was refused, although its requirements are met. +/// +public enum QuestCompletionRefusal +{ + /// + /// The item rewards don't fit into the inventory. + /// + InventoryFull, + + /// + /// The money rewards would exceed the money limit of the inventory. + /// + ZenLimitReached, +} diff --git a/src/GameServer/RemoteView/Quest/QuestCompletionResponsePlugIn.cs b/src/GameServer/RemoteView/Quest/QuestCompletionResponsePlugIn.cs index 42eda8dcb9..9a13ba2023 100644 --- a/src/GameServer/RemoteView/Quest/QuestCompletionResponsePlugIn.cs +++ b/src/GameServer/RemoteView/Quest/QuestCompletionResponsePlugIn.cs @@ -39,7 +39,25 @@ public async ValueTask QuestCompletedAsync(QuestDefinition quest) } else { - await this._player.Connection.SendQuestCompletionResponseAsync((ushort)quest.Number, (ushort)quest.Group, true).ConfigureAwait(false); + await this._player.Connection.SendQuestCompletionResponseAsync((ushort)quest.Number, (ushort)quest.Group, QuestCompletionResponse.QuestCompletionResult.Completed).ConfigureAwait(false); } } + + /// + public async ValueTask QuestCompletionRefusedAsync(QuestDefinition quest, QuestCompletionRefusal refusal) + { + if (quest.Group == QuestConstants.LegacyQuestGroup) + { + // The legacy quest state response has no result for a refusal. + return; + } + + var result = refusal switch + { + QuestCompletionRefusal.InventoryFull => QuestCompletionResponse.QuestCompletionResult.InventoryFull, + QuestCompletionRefusal.ZenLimitReached => QuestCompletionResponse.QuestCompletionResult.ZenLimitReached, + _ => QuestCompletionResponse.QuestCompletionResult.NotCompleted, + }; + await this._player.Connection.SendQuestCompletionResponseAsync((ushort)quest.Number, (ushort)quest.Group, result).ConfigureAwait(false); + } } \ No newline at end of file diff --git a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs index 34f8b7eb24..b70d6a2e76 100644 --- a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs +++ b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs @@ -6577,12 +6577,12 @@ int WritePacket() /// The connection. /// The quest number. /// The quest group. - /// The is quest completed. + /// The result. /// - /// Is sent by the server when: The server acknowledges the completion of a quest. - /// Causes reaction on client side: The client shows the success and possibly requests for the next available quests. + /// Is sent by the server when: The server acknowledges the completion of a quest, or refuses it because the rewards don't fit. + /// Causes reaction on client side: When the quest was completed, the client shows the success and possibly requests for the next available quests. When it was refused, the client shows a message and disables the button to complete the quest. /// - public static async ValueTask SendQuestCompletionResponseAsync(this IConnection? connection, ushort @questNumber, ushort @questGroup, bool @isQuestCompleted) + public static async ValueTask SendQuestCompletionResponseAsync(this IConnection? connection, ushort @questNumber, ushort @questGroup, QuestCompletionResponse.QuestCompletionResult @result) { if (connection is null) { @@ -6595,7 +6595,7 @@ int WritePacket() var packet = new QuestCompletionResponseRef(connection.Output.GetSpan(length)[..length]); packet.QuestNumber = @questNumber; packet.QuestGroup = @questGroup; - packet.IsQuestCompleted = @isQuestCompleted; + packet.Result = @result; return packet.Header.Length; } diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs index 722c669274..1ef7c7fcfd 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs @@ -31566,11 +31566,37 @@ public ushort QuestGroup /// -/// Is sent by the server when: The server acknowledges the completion of a quest. -/// Causes reaction on client side: The client shows the success and possibly requests for the next available quests. +/// Is sent by the server when: The server acknowledges the completion of a quest, or refuses it because the rewards don't fit. +/// Causes reaction on client side: When the quest was completed, the client shows the success and possibly requests for the next available quests. When it was refused, the client shows a message and disables the button to complete the quest. /// public readonly struct QuestCompletionResponse { + /// + /// Defines the result of a quest completion request. + /// + public enum QuestCompletionResult + { + /// + /// The quest wasn't completed. + /// + NotCompleted = 0, + + /// + /// The quest was completed. + /// + Completed = 1, + + /// + /// The quest wasn't completed, because the money of the rewards would exceed the money limit of the inventory. + /// + ZenLimitReached = 2, + + /// + /// The quest wasn't completed, because the item rewards don't fit into the inventory. + /// + InventoryFull = 3, + } + private readonly Memory _data; /// @@ -31645,12 +31671,12 @@ public ushort QuestGroup } /// - /// Gets or sets the is quest completed. + /// Gets or sets the result. /// - public bool IsQuestCompleted + public QuestCompletionResponse.QuestCompletionResult Result { - get => this._data.Span[8..].GetBoolean(); - set => this._data.Span[8..].SetBoolean(value); + get => (QuestCompletionResult)this._data.Span[8]; + set => this._data.Span[8] = (byte)value; } /// diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml index 756ae07ff7..4cc8fff520 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -11405,8 +11405,8 @@ QuestCompletionResponse 9 ServerToClient - The server acknowledges the completion of a quest. - The client shows the success and possibly requests for the next available quests. + The server acknowledges the completion of a quest, or refuses it because the rewards don't fit. + When the quest was completed, the client shows the success and possibly requests for the next available quests. When it was refused, the client shows a message and disables the button to complete the quest. 4 @@ -11420,10 +11420,39 @@ 8 - Boolean - IsQuestCompleted + Enum + QuestCompletionResult + Result + + + QuestCompletionResult + Defines the result of a quest completion request. + + + NotCompleted + The quest wasn't completed. + 0 + + + Completed + The quest was completed. + 1 + + + ZenLimitReached + The quest wasn't completed, because the money of the rewards would exceed the money limit of the inventory. + 2 + + + InventoryFull + The quest wasn't completed, because the item rewards don't fit into the inventory. + 3 + + + + C1HeaderWithSubCode diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs index 29419c2951..930b00679c 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs @@ -29937,8 +29937,8 @@ public ushort QuestGroup /// -/// Is sent by the server when: The server acknowledges the completion of a quest. -/// Causes reaction on client side: The client shows the success and possibly requests for the next available quests. +/// Is sent by the server when: The server acknowledges the completion of a quest, or refuses it because the rewards don't fit. +/// Causes reaction on client side: When the quest was completed, the client shows the success and possibly requests for the next available quests. When it was refused, the client shows a message and disables the button to complete the quest. /// public readonly ref struct QuestCompletionResponseRef { @@ -30016,12 +30016,12 @@ public ushort QuestGroup } /// - /// Gets or sets the is quest completed. + /// Gets or sets the result. /// - public bool IsQuestCompleted + public QuestCompletionResponse.QuestCompletionResult Result { - get => this._data[8..].GetBoolean(); - set => this._data[8..].SetBoolean(value); + get => (QuestCompletionResponse.QuestCompletionResult)this._data[8]; + set => this._data[8] = (byte)value; } /// diff --git a/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs b/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs index 6f3d8d70a5..4f422f0d0d 100644 --- a/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs +++ b/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs @@ -6587,9 +6587,9 @@ public void QuestCompletionResponse_PacketSizeValidation() Assert.That(6 + 2, Is.LessThanOrEqualTo(expectedLength), "Field 'QuestGroup' exceeds packet boundary"); - // Validate field 'IsQuestCompleted' boundary + // Validate field 'Result' boundary Assert.That(8 + 1, Is.LessThanOrEqualTo(expectedLength), - "Field 'IsQuestCompleted' exceeds packet boundary"); + "Field 'Result' exceeds packet boundary"); } /// diff --git a/tests/MUnique.OpenMU.Tests/QuestRewardSpaceTest.cs b/tests/MUnique.OpenMU.Tests/QuestRewardSpaceTest.cs new file mode 100644 index 0000000000..7ce4f1f63d --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/QuestRewardSpaceTest.cs @@ -0,0 +1,185 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using Moq; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Configuration.Quests; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.PlayerActions.Quests; +using MUnique.OpenMU.GameLogic.Views.Quest; +using BasicModel = MUnique.OpenMU.Persistence.BasicModel; + +/// +/// Tests that a quest isn't completed while its rewards don't fit, and the check if items fit into a storage. +/// +[TestFixture] +public class QuestRewardSpaceTest +{ + private const byte FirstInventorySlot = 12; + + private const int InventoryCells = 64; + + private readonly ItemDefinition _handInDefinition = new () { Width = 1, Height = 1, Durability = 1 }; + + /// + /// Tests that items which fit are reported as fitting, and that the storage doesn't change. + /// + [Test] + public void ItemsFitIntoEmptyStorage() + { + var storage = new Storage(16, 0, 0, CreateItemStorage()); + + Assert.That(storage.CanAddItems([CreateItem(2, 2), CreateItem(2, 2), CreateItem(4, 2)]), Is.True); + Assert.That(storage.FreeSlots.Count(), Is.EqualTo(16)); + } + + /// + /// Tests that items don't fit when the second one finds no place after the first one took its place. + /// + [Test] + public void ItemsDontFitTogether() + { + // 2 rows: one 8x2 item takes everything. + var storage = new Storage(16, 0, 0, CreateItemStorage()); + + Assert.That(storage.CanAddItems([CreateItem(8, 2)]), Is.True); + Assert.That(storage.CanAddItems([CreateItem(8, 2), CreateItem(1, 1)]), Is.False); + } + + /// + /// Tests that the place of the removed items counts as free. + /// + [Test] + public void RemovedItemsMakeRoom() + { + var items = Enumerable.Range(0, 16).Select(slot => CreateItem(1, 1, (byte)slot)).ToList(); + var storage = new Storage(16, 0, 0, CreateItemStorage([.. items])); + + Assert.That(storage.CanAddItems([CreateItem(1, 1)]), Is.False); + Assert.That(storage.CanAddItems([CreateItem(1, 1)], [items[5]]), Is.True); + Assert.That(storage.CanAddItems([CreateItem(2, 2)], [items[0], items[1], items[8]]), Is.False); + Assert.That(storage.CanAddItems([CreateItem(2, 2)], [items[0], items[1], items[8], items[9]]), Is.True); + Assert.That(storage.FreeSlots, Is.Empty); + } + + /// + /// Tests that the inventory extensions count, too. + /// + [Test] + public void ExtensionsCount() + { + var items = Enumerable.Range(0, 16).Select(slot => CreateItem(1, 1, (byte)slot)).ToList(); + var storage = new StorageWithExtension(new Storage(16, 0, 0, CreateItemStorage([.. items])), new Storage(8, 0, 16, CreateItemStorage())); + + Assert.That(storage.CanAddItems([CreateItem(2, 1), CreateItem(2, 1), CreateItem(4, 1)]), Is.True); + Assert.That(storage.CanAddItems([CreateItem(2, 1), CreateItem(2, 1), CreateItem(4, 1), CreateItem(1, 1)]), Is.False); + } + + /// + /// Tests that a quest is completed when its item reward fits into the place of the handed in item. + /// + [Test] + public async ValueTask QuestIsCompletedWhenRewardFitsAfterHandInAsync() + { + var (player, quest, questState) = await this.CreatePlayerWithFullInventoryAsync(CreateItem(1, 1)).ConfigureAwait(false); + + await new QuestCompletionAction().CompleteQuestAsync(player, quest.Group, quest.Number).ConfigureAwait(false); + + Assert.That(questState.ActiveQuest, Is.Null); + Assert.That(player.Inventory!.Items.Count(i => i.Definition == this._handInDefinition), Is.EqualTo(InventoryCells - 1)); + Mock.Get(player.ViewPlugIns.GetPlugIn()!).Verify(p => p.QuestCompletedAsync(quest), Times.Once); + } + + /// + /// Tests that a quest isn't completed while its item reward doesn't fit, and that nothing is taken. + /// + [Test] + public async ValueTask QuestIsRefusedWhenRewardDoesntFitAsync() + { + var (player, quest, questState) = await this.CreatePlayerWithFullInventoryAsync(CreateItem(2, 2)).ConfigureAwait(false); + + await new QuestCompletionAction().CompleteQuestAsync(player, quest.Group, quest.Number).ConfigureAwait(false); + + Assert.That(questState.ActiveQuest, Is.SameAs(quest)); + Assert.That(player.Inventory!.Items.Count(), Is.EqualTo(InventoryCells)); + var view = Mock.Get(player.ViewPlugIns.GetPlugIn()!); + view.Verify(p => p.QuestCompletionRefusedAsync(quest, QuestCompletionRefusal.InventoryFull), Times.Once); + view.Verify(p => p.QuestCompletedAsync(It.IsAny()), Times.Never); + } + + /// + /// Tests that a quest isn't completed while its money reward would exceed the money limit. + /// + [Test] + public async ValueTask QuestIsRefusedWhenMoneyExceedsLimitAsync() + { + var (player, quest, questState) = await this.CreatePlayerWithFullInventoryAsync(CreateItem(1, 1)).ConfigureAwait(false); + var moneyReward = new BasicModel.QuestReward { RewardType = QuestRewardType.Money, Value = 1000 }; + quest.Rewards.Add(moneyReward); + player.GameContext.Configuration.MaximumInventoryMoney = 5000; + player.Money = 4500; + + await new QuestCompletionAction().CompleteQuestAsync(player, quest.Group, quest.Number).ConfigureAwait(false); + + Assert.That(questState.ActiveQuest, Is.SameAs(quest)); + Assert.That(player.Money, Is.EqualTo(4500)); + Mock.Get(player.ViewPlugIns.GetPlugIn()!).Verify(p => p.QuestCompletionRefusedAsync(quest, QuestCompletionRefusal.ZenLimitReached), Times.Once); + } + + private static Item CreateItem(byte width, byte height, byte slot = 0) + { + // The items are compared by their identifier. + return new BasicModel.Item + { + Id = Guid.NewGuid(), + Definition = new ItemDefinition { Width = width, Height = height, Durability = 1 }, + ItemSlot = slot, + Durability = 1, + }; + } + + private static ItemStorage CreateItemStorage(params Item[] items) + { + var storage = new Mock(); + storage.Setup(s => s.Items).Returns(items.ToList()); + return storage.Object; + } + + /// + /// Creates a player with a full inventory of items which are handed in, and an active quest which takes one of them + /// and rewards the given item. + /// + private async ValueTask<(Player Player, QuestDefinition Quest, CharacterQuestState QuestState)> CreatePlayerWithFullInventoryAsync(Item rewardItem) + { + var player = await PlayerTestHelper.CreatePlayerAsync(GameContextTestHelper.CreateGameContext()).ConfigureAwait(false); + for (var slot = FirstInventorySlot; slot < FirstInventorySlot + InventoryCells; slot++) + { + var item = new BasicModel.Item { Id = Guid.NewGuid(), Definition = this._handInDefinition, Durability = 1 }; + await player.Inventory!.AddItemAsync((byte)slot, item).ConfigureAwait(false); + } + + var quest = new BasicModel.QuestDefinition { Group = 5, Number = 7, StartingNumber = 6 }; + quest.RequiredItems.Add(new BasicModel.QuestItemRequirement { Item = this._handInDefinition, MinimumNumber = 1 }); + quest.Rewards.Add(new BasicModel.QuestReward { RewardType = QuestRewardType.Item, ItemReward = rewardItem }); + + var questState = new BasicModel.CharacterQuestState { Group = quest.Group, ActiveQuest = quest }; + Mock.Get(player.SelectedCharacter!).Setup(c => c.QuestStates).Returns(new List { questState }); + return (player, quest, questState); + } + + /// + /// A storage without own slots, whose extensions are a main storage and an extension, like the inventory. + /// + private sealed class StorageWithExtension : Storage + { + public StorageWithExtension(Storage main, Storage extension) + : base(0, main.ItemStorage) + { + this.Extensions = [main, extension]; + } + } +}