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]; + } + } +}