Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions docs/Packets/C1-F6-0D-QuestCompletionResponse_by-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
| 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. |
9 changes: 9 additions & 0 deletions src/GameLogic/IStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ public interface IStorage
/// <returns>The index of a slot in which the item would fit.</returns>
byte? CheckInvSpace(Item item);

/// <summary>
/// Determines whether all the items would fit into this storage (including extensions),
/// after the other items were removed, without changing the storage.
/// </summary>
/// <param name="items">The items which would be added, in this order, each at the slot which <see cref="CheckInvSpace"/> would find.</param>
/// <param name="removedItems">The items of this storage which would be removed before.</param>
/// <returns><c>true</c>, if all the items would fit; otherwise, <c>false</c>.</returns>
bool CanAddItems(IEnumerable<Item> items, IEnumerable<Item>? removedItems = null);

/// <summary>
/// Checks if the items of another storage will fit into this storage (including extensions), and adds them if possible.
/// </summary>
Expand Down
67 changes: 55 additions & 12 deletions src/GameLogic/PlayerActions/Quests/QuestCompletionAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IQuestCompletionResponsePlugIn>(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)
Expand All @@ -99,6 +97,51 @@ public async ValueTask CompleteQuestAsync(Player player, short group, short numb
await player.InvokeViewPlugInAsync<IQuestCompletionResponsePlugIn>(p => p.QuestCompletedAsync(activeQuest)).ConfigureAwait(false);
}

/// <summary>
/// Gets the items of the inventory which are handed in for the required items of the quest.
/// </summary>
private static List<Item> GetHandedInItems(Player player, QuestDefinition quest)
{
var handedInItems = new List<Item>();
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;
}

/// <summary>
/// Gets the reason why the player can't take the rewards of the quest, or <c>null</c> 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.
/// </summary>
private static QuestCompletionRefusal? GetRefusal(Player player, QuestDefinition quest, IReadOnlyCollection<Item> handedInItems)
{
var itemRewards = quest.Rewards
.Where(reward => reward.RewardType == QuestRewardType.Item)
.Select(reward => reward.ItemReward)
.OfType<Item>()
.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)
Expand Down
90 changes: 90 additions & 0 deletions src/GameLogic/Storage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,41 @@ public bool TryRemoveMoney(int value)
return null;
}

/// <inheritdoc/>
public bool CanAddItems(IEnumerable<Item> items, IEnumerable<Item>? 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;
}

/// <inheritdoc/>
public async ValueTask<bool> TryTakeAllAsync(IStorage anotherStorage)
{
Expand Down Expand Up @@ -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);
Expand All @@ -393,6 +474,15 @@ private bool AddItem(byte slot, Item item)
return result;
}

private IEnumerable<Storage> GetStoragesInSearchOrder()
{
yield return this;
foreach (var extension in this.Extensions.OfType<Storage>().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));
Expand Down
7 changes: 7 additions & 0 deletions src/GameLogic/Views/Quest/IQuestCompletionResponsePlugIn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,11 @@ public interface IQuestCompletionResponsePlugIn : IViewPlugIn
/// The success/state of the quest can be determined by inspecting the player object.
/// </remarks>
ValueTask QuestCompletedAsync(QuestDefinition quest);

/// <summary>
/// Shows that the completion of the specified quest was refused, although its requirements are met.
/// </summary>
/// <param name="quest">The quest.</param>
/// <param name="refusal">The reason of the refusal.</param>
ValueTask QuestCompletionRefusedAsync(QuestDefinition quest, QuestCompletionRefusal refusal);
}
21 changes: 21 additions & 0 deletions src/GameLogic/Views/Quest/QuestCompletionRefusal.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// <copyright file="QuestCompletionRefusal.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.GameLogic.Views.Quest;

/// <summary>
/// The reason why the completion of a quest was refused, although its requirements are met.
/// </summary>
public enum QuestCompletionRefusal
{
/// <summary>
/// The item rewards don't fit into the inventory.
/// </summary>
InventoryFull,

/// <summary>
/// The money rewards would exceed the money limit of the inventory.
/// </summary>
ZenLimitReached,
}
20 changes: 19 additions & 1 deletion src/GameServer/RemoteView/Quest/QuestCompletionResponsePlugIn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

/// <inheritdoc />
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);
}
}
10 changes: 5 additions & 5 deletions src/Network/Packets/ServerToClient/ConnectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6577,12 +6577,12 @@ int WritePacket()
/// <param name="connection">The connection.</param>
/// <param name="questNumber">The quest number.</param>
/// <param name="questGroup">The quest group.</param>
/// <param name="isQuestCompleted">The is quest completed.</param>
/// <param name="result">The result.</param>
/// <remarks>
/// 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.
/// </remarks>
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)
{
Expand All @@ -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;
}
Expand Down
38 changes: 32 additions & 6 deletions src/Network/Packets/ServerToClient/ServerToClientPackets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31566,11 +31566,37 @@ public ushort QuestGroup


/// <summary>
/// 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.
/// </summary>
public readonly struct QuestCompletionResponse
{
/// <summary>
/// Defines the result of a quest completion request.
/// </summary>
public enum QuestCompletionResult
{
/// <summary>
/// The quest wasn't completed.
/// </summary>
NotCompleted = 0,

/// <summary>
/// The quest was completed.
/// </summary>
Completed = 1,

/// <summary>
/// The quest wasn't completed, because the money of the rewards would exceed the money limit of the inventory.
/// </summary>
ZenLimitReached = 2,

/// <summary>
/// The quest wasn't completed, because the item rewards don't fit into the inventory.
/// </summary>
InventoryFull = 3,
}

private readonly Memory<byte> _data;

/// <summary>
Expand Down Expand Up @@ -31645,12 +31671,12 @@ public ushort QuestGroup
}

/// <summary>
/// Gets or sets the is quest completed.
/// Gets or sets the result.
/// </summary>
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;
}

/// <summary>
Expand Down
Loading