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