diff --git a/docs-website/docs/server-features/inventory-charms.md b/docs-website/docs/server-features/inventory-charms.md new file mode 100644 index 0000000000..fe3b3610f3 --- /dev/null +++ b/docs-website/docs/server-features/inventory-charms.md @@ -0,0 +1,59 @@ +--- +title: Figurines and charms +sidebar_label: Figurines and charms +description: Figurines and charms give a bonus while used in the inventory. +--- + +The figurines and charms are items of the in-game shop. They are not worn in +an equipment slot. Instead, the player right clicks them inside the inventory +to use them, and the bonus applies as long as they stay in use there. Another +right click stops the use. + +| Item | Number | Bonus | +| ------------------------ | ------ | ------------------------------ | +| Hawk Figurine | 13/128 | +10 critical damage | +| Goat Figurine | 13/129 | +10 excellent damage | +| Oak Charm | 13/130 | +50 maximum health | +| Maple Charm | 13/131 | +50 maximum mana | +| Golden Oak Charm | 13/132 | +150 health, +50 shield | +| Golden Maple Charm | 13/133 | +150 mana, +50 ability (AG) | + +The values are the ones which the game client shows in the item tooltip. They +are stored as power-ups of the item definitions, so they can be changed in the +admin panel. + +## Rules + +* Only one item of the same kind can be in use at the same time. Using a + second one is refused with a message. +* The item keeps its state when it is moved inside the inventory. +* When the item leaves the inventory, for example when it is dropped or moved + into the vault, it is no longer in use, and the bonus is removed. +* Like in the original game, the state is stored in the durability of the + item: 254 means in use, 255 means not in use. A new item starts with 255 + when it is added to the inventory. The item definitions have a durability + of 1, so the items are not stackable. + +## Configuration + +The *Items used inside the inventory* feature plugin defines which items can +be used inside the inventory. By default, these are the items 13/128 to +13/134. When the plugin is deactivated, no item can be used this way. + +## Update of existing databases + +The update *Add the figurines and charms* adds the items, and links the +products of the cash shop to them. Their packages were not for sale before, +because their products had no item. The update leaves them that way, so the +administrator can put them on sale in the admin panel. + +## Protocol + +The client sends the request `C1 BF 20` with the inventory slot and the +requested state. The server answers with the same packet and the new state of +the item. + +:::note +The Worn Horseshoe (13/134) is not added yet, because there is no attribute +for the item drop rate yet. +::: diff --git a/docs-website/sidebars.js b/docs-website/sidebars.js index 2268090ad8..02029cd73b 100644 --- a/docs-website/sidebars.js +++ b/docs-website/sidebars.js @@ -65,6 +65,7 @@ const sidebars = { "server-features/anti-cheat", "server-features/test-actors", "server-features/gens", + "server-features/inventory-charms", "server-features/cash-shop", "server-features/discord", "server-features/player-shop-currencies", diff --git a/docs/Progress.md b/docs/Progress.md index 7997328639..4c8ad82676 100644 --- a/docs/Progress.md +++ b/docs/Progress.md @@ -88,6 +88,7 @@ complexity and effort). Complexity 0 means we wont implement it. | *JewelMix* | 0xBC | 100% | 4 | | | *CrywolfGroup* | 0xBD | 100% | 10 | | | *IllusionTempleGroup* | 0xBF | 100% | 10 | Team-based PvP mini game: relic pickup/delivery, scoring, skill points, special skills, rewards | +| *InventoryItemEquip* | 0xBF20 | 100% | 2 | Using figurines and charms inside the inventory | | FriendListRequest | 0xC0 | 0% | 0 | Not needed, friend list is sent automatically | | *FriendAdd* | 0xC1 | 100% | 2 | | | *WaitFriendAdd* | 0xC2 | 100% | 2 | | diff --git a/src/GameLogic/InventoryItemUseConfiguration.cs b/src/GameLogic/InventoryItemUseConfiguration.cs new file mode 100644 index 0000000000..4e6ac635be --- /dev/null +++ b/src/GameLogic/InventoryItemUseConfiguration.cs @@ -0,0 +1,39 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +using MUnique.OpenMU.DataModel.Configuration.Items; + +/// +/// The configuration of the items which are used inside the inventory, like the figurines and charms. +/// +public class InventoryItemUseConfiguration +{ + /// + /// Gets or sets the items which are used inside the inventory instead of being equipped. + /// + /// + /// By default, these are the figurines (group 13, number 128 and 129), the charms (130 to 133) + /// and the worn horseshoe (134), which the game client requests to use with a right click in the inventory. + /// + public IList Items { get; set; } = CreateDefaultItems(); + + /// + /// Determines whether the item definition belongs to an item which is used inside the inventory. + /// + /// The item definition. + /// true, if items of this definition are used inside the inventory; otherwise, false. + public bool Contains(ItemDefinition definition) + { + return this.Items.Any(id => id.Group == definition.Group && id.Number == definition.Number); + } + + private static IList CreateDefaultItems() + { + return Enumerable.Range(128, 7) + .Select(number => new InventoryUsedItemIdentifier { Group = 13, Number = (short)number }) + .ToList(); + } +} diff --git a/src/GameLogic/InventoryItemUseFeaturePlugIn.cs b/src/GameLogic/InventoryItemUseFeaturePlugIn.cs new file mode 100644 index 0000000000..a4d5399f6d --- /dev/null +++ b/src/GameLogic/InventoryItemUseFeaturePlugIn.cs @@ -0,0 +1,40 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.PlugIns; + +/// +/// The use of items inside the inventory, like the figurines and charms. +/// While such an item is in use, its power-ups apply, although it isn't equipped. +/// When it's deactivated, no item can be used inside the inventory. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.InventoryItemUseFeaturePlugIn_Name), Description = nameof(PlugInResources.InventoryItemUseFeaturePlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("0C9C3D52-7B0E-4E55-9D1B-2E6A35C4F1A8")] +public class InventoryItemUseFeaturePlugIn : IFeaturePlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration +{ + /// + public InventoryItemUseConfiguration? Configuration { get; set; } + + /// + /// Gets the configuration of the items which are used inside the inventory. + /// + /// The game context. + /// The configuration; null, if the feature is deactivated. + public static InventoryItemUseConfiguration? GetActiveConfiguration(IGameContext gameContext) + { + return gameContext.FeaturePlugIns.GetPlugIn() is { } plugIn + ? plugIn.Configuration ?? new InventoryItemUseConfiguration() + : null; + } + + /// + public object CreateDefaultConfig() + { + return new InventoryItemUseConfiguration(); + } +} diff --git a/src/GameLogic/InventoryStorage.cs b/src/GameLogic/InventoryStorage.cs index 4a38a197c8..4478d81e2d 100644 --- a/src/GameLogic/InventoryStorage.cs +++ b/src/GameLogic/InventoryStorage.cs @@ -19,6 +19,8 @@ public class InventoryStorage : Storage, IInventoryStorage private readonly Player _player; + private Item? _itemMovedInsideInventory; + /// /// Initializes a new instance of the class. /// @@ -113,6 +115,22 @@ public override async ValueTask AddItemAsync(byte slot, Item item) { await this.RaiseEquippedItemsChangedAsync(convertedItem ?? item, true).ConfigureAwait(false); } + else + { + this.OnItemAddedToInventory(convertedItem ?? item); + } + } + + return success; + } + + /// + public override async ValueTask AddItemAsync(Item item) + { + var success = await base.AddItemAsync(item).ConfigureAwait(false); + if (success && !this.IsWearingSlot(item.ItemSlot)) + { + this.OnItemAddedToInventory(item); } return success; @@ -127,6 +145,23 @@ public override async ValueTask RemoveItemAsync(Item item) { await this.RaiseEquippedItemsChangedAsync(item, false).ConfigureAwait(false); } + else + { + this.OnItemRemovedFromInventory(item); + } + } + + /// + /// Keeps the use state and the power-ups of an item which is used inside the inventory, + /// while it's moved to another slot of the inventory, until the returned object is disposed. + /// Otherwise, removing the item would stop its use, like when it leaves the inventory. + /// + /// The item which is moved inside the inventory. + /// The object which ends keeping the use state when it's disposed. + public IDisposable KeepInventoryUseWhileMoving(Item item) + { + this._itemMovedInsideInventory = item; + return new MoveInsideInventoryScope(this); } /// @@ -151,6 +186,52 @@ public async ValueTask RaiseEquippedItemsChangedAsync(Item item, bool isEquipped } } + /// + /// Handles an item which was added to an inventory slot which isn't equippable. + /// An item which is used inside the inventory gets its power-ups while it's in use. + /// + /// The item. + private void OnItemAddedToInventory(Item item) + { + if (this._player.Attributes is not { } attributes + || !item.IsUsedInsideInventory(this._gameContext)) + { + return; + } + + if (item.IsInUseInsideInventory(this._gameContext) && attributes.ItemPowerUps.ContainsKey(item)) + { + // It's still in use, e.g. after it was moved inside the inventory. + return; + } + + if ((int)item.Durability != InventoryUsedItemExtensions.InUseDurability + || this.Items.Any(other => other != item && other.Definition == item.Definition && other.IsInUseInsideInventory(this._gameContext))) + { + // A new item starts without being used, and only one item of the same kind can be used at the same time. + item.Durability = InventoryUsedItemExtensions.NotInUseDurability; + } + + attributes.UpdateInventoryUsedItemPowerUps(item, this._gameContext); + } + + /// + /// Handles an item which was removed from an inventory slot which isn't equippable. + /// An item which is used inside the inventory stops being used when it leaves the inventory, e.g. when it's dropped. + /// + /// The item. + private void OnItemRemovedFromInventory(Item item) + { + if (item == this._itemMovedInsideInventory + || !item.IsUsedInsideInventory(this._gameContext)) + { + return; + } + + item.Durability = InventoryUsedItemExtensions.NotInUseDurability; + this._player.Attributes?.RemoveInventoryUsedItemPowerUps(item); + } + private bool IsWearingSlot(int slot) { return slot >= FirstEquippableItemSlotIndex && slot <= LastEquippableItemSlotIndex; @@ -205,14 +286,18 @@ private void InitializePowerUps() powerUp.Dispose(); } - var factory = this._gameContext?.ItemPowerUpFactory; - if (factory != null) + if (this._gameContext?.ItemPowerUpFactory is { } factory) { foreach (var item in this.EquippedItems) { this._player.Attributes.ItemPowerUps.Add(item, factory.GetPowerUps(item, this._player.Attributes).ToList()); } + foreach (var item in this.Items.Where(item => item.IsInUseInsideInventory(this._gameContext))) + { + this._player.Attributes.ItemPowerUps.Add(item, factory.GetPowerUps(item, this._player.Attributes).ToList()); + } + this.UpdateSetPowerUps(); } else @@ -239,4 +324,27 @@ private void UpdateSetPowerUps() var factory = this._gameContext.ItemPowerUpFactory; this._player.Attributes.ItemSetPowerUps = factory.GetSetPowerUps(this.EquippedItems, this._player.Attributes, this._player.GameContext.Configuration).ToList(); } + + /// + /// The scope in which an item is moved inside the inventory, see . + /// + private sealed class MoveInsideInventoryScope : IDisposable + { + private readonly InventoryStorage _inventory; + + /// + /// Initializes a new instance of the class. + /// + /// The inventory. + public MoveInsideInventoryScope(InventoryStorage inventory) + { + this._inventory = inventory; + } + + /// + public void Dispose() + { + this._inventory._itemMovedInsideInventory = null; + } + } } diff --git a/src/GameLogic/InventoryUsedItemExtensions.cs b/src/GameLogic/InventoryUsedItemExtensions.cs new file mode 100644 index 0000000000..5572b0a7ad --- /dev/null +++ b/src/GameLogic/InventoryUsedItemExtensions.cs @@ -0,0 +1,95 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.Attributes; + +/// +/// Extensions for the items which are used inside the inventory, see . +/// Like in the game client, the durability of such an item tells whether it's in use: +/// when it is, when it isn't. +/// While it's in use, its power-ups apply, although it isn't equipped. +/// +public static class InventoryUsedItemExtensions +{ + /// + /// The durability of an item which is in use. It's the value which the game client expects. + /// + internal const byte InUseDurability = 254; + + /// + /// The durability of an item which isn't in use. It's the value which the game client expects. + /// + internal const byte NotInUseDurability = 255; + + /// + /// Determines whether the item is one which is used inside the inventory. + /// + /// The item. + /// The game context. + /// true, if the item is used inside the inventory; otherwise, false. + public static bool IsUsedInsideInventory(this Item item, IGameContext gameContext) + { + return item.Definition is { } definition + && InventoryItemUseFeaturePlugIn.GetActiveConfiguration(gameContext) is { } configuration + && configuration.Contains(definition); + } + + /// + /// Determines whether the item is used inside the inventory, and currently in use. + /// + /// The item. + /// The game context. + /// true, if the item is in use inside the inventory; otherwise, false. + public static bool IsInUseInsideInventory(this Item item, IGameContext gameContext) + { + return item.HasInUseState() && item.IsUsedInsideInventory(gameContext); + } + + /// + /// Determines whether the item has the state of an item which is in use inside the inventory: + /// it has the and it isn't equipped. + /// + /// The item. + /// true, if the item has the state of an item which is in use inside the inventory; otherwise, false. + internal static bool HasInUseState(this Item item) + { + return (int)item.Durability == InUseDurability + && (item.ItemSlot < InventoryConstants.FirstEquippableItemSlotIndex || item.ItemSlot > InventoryConstants.LastEquippableItemSlotIndex); + } + + /// + /// Updates the power-ups of an item which is used inside the inventory: + /// they're removed, and added again when the item is in use. + /// + /// The attribute system of the player. + /// The item. + /// The game context. + internal static void UpdateInventoryUsedItemPowerUps(this ItemAwareAttributeSystem attributes, Item item, IGameContext gameContext) + { + attributes.RemoveInventoryUsedItemPowerUps(item); + if (item.IsInUseInsideInventory(gameContext) && gameContext.ItemPowerUpFactory is { } factory) + { + attributes.ItemPowerUps.Add(item, factory.GetPowerUps(item, attributes).ToList()); + } + } + + /// + /// Removes the power-ups of an item which is used inside the inventory, e.g. when it leaves the inventory. + /// + /// The attribute system of the player. + /// The item. + internal static void RemoveInventoryUsedItemPowerUps(this ItemAwareAttributeSystem attributes, Item item) + { + if (attributes.ItemPowerUps.Remove(item, out var powerUps)) + { + foreach (var powerUp in powerUps) + { + powerUp.Dispose(); + } + } + } +} diff --git a/src/GameLogic/InventoryUsedItemIdentifier.cs b/src/GameLogic/InventoryUsedItemIdentifier.cs new file mode 100644 index 0000000000..79e7e7e690 --- /dev/null +++ b/src/GameLogic/InventoryUsedItemIdentifier.cs @@ -0,0 +1,27 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +/// +/// Identifies an item definition which is used inside the inventory. +/// +public class InventoryUsedItemIdentifier +{ + /// + /// Gets or sets the group of the item definition. + /// + public byte Group { get; set; } + + /// + /// Gets or sets the number of the item definition. + /// + public short Number { get; set; } + + /// + public override string ToString() + { + return $"{this.Group}/{this.Number}"; + } +} diff --git a/src/GameLogic/ItemPowerUpFactory.cs b/src/GameLogic/ItemPowerUpFactory.cs index 1602f4d4be..dd562f0a95 100644 --- a/src/GameLogic/ItemPowerUpFactory.cs +++ b/src/GameLogic/ItemPowerUpFactory.cs @@ -57,7 +57,8 @@ public IEnumerable GetPowerUps(Item item, AttributeSystem attrib yield break; } - if (item.ItemSlot < InventoryConstants.FirstEquippableItemSlotIndex || item.ItemSlot > InventoryConstants.LastEquippableItemSlotIndex) + if ((item.ItemSlot < InventoryConstants.FirstEquippableItemSlotIndex || item.ItemSlot > InventoryConstants.LastEquippableItemSlotIndex) + && !item.HasInUseState()) { yield break; } diff --git a/src/GameLogic/PlayerActions/Items/MoveItemAction.cs b/src/GameLogic/PlayerActions/Items/MoveItemAction.cs index e84e15a17e..dd6fb442b1 100644 --- a/src/GameLogic/PlayerActions/Items/MoveItemAction.cs +++ b/src/GameLogic/PlayerActions/Items/MoveItemAction.cs @@ -170,6 +170,11 @@ private async ValueTask PartiallyStackAsync(Player player, Item sourceItem, Item private async ValueTask MoveNormalAsync(Player player, Storages fromStorage, byte fromSlot, Storages toStorage, byte toSlot, IStorage fromItemStorage, Item item, IStorage toItemStorage) { var wasEquipped = fromItemStorage == player.Inventory && fromSlot <= LastEquippableItemSlotIndex; + + // An item which is used inside the inventory, like a charm, stays in use while it's moved inside the inventory. + using var keepInventoryUse = fromItemStorage == player.Inventory && toItemStorage == player.Inventory && toSlot > LastEquippableItemSlotIndex + ? player.Inventory.AsInventoryStorage?.KeepInventoryUseWhileMoving(item) + : null; await fromItemStorage.RemoveItemAsync(item).ConfigureAwait(false); var wasAdded = await toItemStorage.AddItemAsync(toSlot, item).ConfigureAwait(false); if (!wasAdded || (wasEquipped && !this.CompliesWithMapRequirements(player))) diff --git a/src/GameLogic/PlayerActions/Items/UseInventoryItemAction.cs b/src/GameLogic/PlayerActions/Items/UseInventoryItemAction.cs new file mode 100644 index 0000000000..3023898dd7 --- /dev/null +++ b/src/GameLogic/PlayerActions/Items/UseInventoryItemAction.cs @@ -0,0 +1,53 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlayerActions.Items; + +using MUnique.OpenMU.GameLogic.Views.Inventory; + +/// +/// The action to start or stop using an item inside the inventory, like a figurine or a charm, +/// see . +/// While such an item is in use, its power-ups apply. +/// +public class UseInventoryItemAction +{ + /// + /// Starts or stops using the item of the inventory slot. + /// + /// The player. + /// The inventory slot of the item. + /// The requested durability, which tells whether the item should be in use, + /// see . + public async ValueTask SetItemInUseAsync(Player player, byte slot, byte requestedDurability) + { + if (player.PlayerState.CurrentState != PlayerState.EnteredWorld + || player.Inventory?.GetItem(slot) is not { } item + || slot <= InventoryConstants.LastEquippableItemSlotIndex + || !item.IsUsedInsideInventory(player.GameContext) + || player.Attributes is not { } attributes) + { + return; + } + + if (requestedDurability is not (InventoryUsedItemExtensions.InUseDurability or InventoryUsedItemExtensions.NotInUseDurability)) + { + player.Logger.LogWarning("Player {player} requested durability {requestedDurability} for {item}, which isn't possible.", player, requestedDurability, item); + await player.InvokeViewPlugInAsync(p => p.InventoryItemUseChangedAsync(item)).ConfigureAwait(false); + return; + } + + if (requestedDurability == InventoryUsedItemExtensions.InUseDurability + && player.Inventory.Items.Any(other => other != item && other.Definition == item.Definition && other.IsInUseInsideInventory(player.GameContext))) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.SameItemAlreadyInUse)).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.InventoryItemUseChangedAsync(item)).ConfigureAwait(false); + return; + } + + item.Durability = requestedDurability; + attributes.UpdateInventoryUsedItemPowerUps(item, player.GameContext); + await player.InvokeViewPlugInAsync(p => p.InventoryItemUseChangedAsync(item)).ConfigureAwait(false); + } +} diff --git a/src/GameLogic/Properties/PlayerMessage.Designer.cs b/src/GameLogic/Properties/PlayerMessage.Designer.cs index 451d767b67..1f38628ad8 100644 --- a/src/GameLogic/Properties/PlayerMessage.Designer.cs +++ b/src/GameLogic/Properties/PlayerMessage.Designer.cs @@ -3198,5 +3198,14 @@ public static string ItemBoxRequiresLevel { return ResourceManager.GetString("ItemBoxRequiresLevel", resourceCulture); } } + + /// + /// Looks up a localized string similar to The same item is already in use. Stop using it first, and then try again.. + /// + public static string SameItemAlreadyInUse { + get { + return ResourceManager.GetString("SameItemAlreadyInUse", resourceCulture); + } + } } } diff --git a/src/GameLogic/Properties/PlayerMessage.de.resx b/src/GameLogic/Properties/PlayerMessage.de.resx index c6b035c784..2e6cfd39f2 100644 --- a/src/GameLogic/Properties/PlayerMessage.de.resx +++ b/src/GameLogic/Properties/PlayerMessage.de.resx @@ -1021,4 +1021,7 @@ Inventarerweiterungen: {18} Dieser Warp ist nur für Game Master verfügbar. + + Der gleiche Gegenstand wird bereits benutzt. Beende zuerst dessen Benutzung und versuche es dann erneut. + diff --git a/src/GameLogic/Properties/PlayerMessage.pt.resx b/src/GameLogic/Properties/PlayerMessage.pt.resx index 3f21ddfa66..5358ab691e 100644 --- a/src/GameLogic/Properties/PlayerMessage.pt.resx +++ b/src/GameLogic/Properties/PlayerMessage.pt.resx @@ -1174,4 +1174,7 @@ Extensões de inventário: {18} Este warp está disponível apenas para game masters. + + O mesmo item já está em uso. Pare de usá-lo primeiro e tente novamente. + diff --git a/src/GameLogic/Properties/PlayerMessage.resx b/src/GameLogic/Properties/PlayerMessage.resx index 95b8eb4d9c..93162a765f 100644 --- a/src/GameLogic/Properties/PlayerMessage.resx +++ b/src/GameLogic/Properties/PlayerMessage.resx @@ -1174,4 +1174,7 @@ Inventory Extensions: {18} This warp is only available to game masters. + + The same item is already in use. Stop using it first, and then try again. + diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index afe520fe7a..31c6ecd4e0 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -7710,5 +7710,23 @@ public static string WorldChatChatCommandPlugIn_Name { return ResourceManager.GetString("WorldChatChatCommandPlugIn_Name", resourceCulture); } } + + /// + /// Looks up a localized string similar to Items used inside the inventory. + /// + public static string InventoryItemUseFeaturePlugIn_Name { + get { + return ResourceManager.GetString("InventoryItemUseFeaturePlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Items like the figurines and charms are used inside the inventory. While such an item is in use, its power-ups apply.. + /// + public static string InventoryItemUseFeaturePlugIn_Description { + get { + return ResourceManager.GetString("InventoryItemUseFeaturePlugIn_Description", resourceCulture); + } + } } } diff --git a/src/GameLogic/Properties/PlugInResources.de.resx b/src/GameLogic/Properties/PlugInResources.de.resx index c476911ee9..ef567a1149 100644 --- a/src/GameLogic/Properties/PlugInResources.de.resx +++ b/src/GameLogic/Properties/PlugInResources.de.resx @@ -2016,4 +2016,10 @@ Teilt dem Client beim Betreten des Spiels mit, welche Warps der Spieler im Warp-Fenster auswählen kann. + + Im Inventar benutzte Gegenstände + + + Gegenstände wie die Figuren und Talismane werden im Inventar benutzt. Solange so ein Gegenstand benutzt wird, gelten seine Boni. + diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index e212c3adc8..2ebfedf5b7 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -2061,4 +2061,10 @@ Tells the client which warps the player can select in the warp window, when the player enters the game. + + Items used inside the inventory + + + Items like the figurines and charms are used inside the inventory. While such an item is in use, its power-ups apply. + diff --git a/src/GameLogic/Properties/PlugInResources.zh-CN.resx b/src/GameLogic/Properties/PlugInResources.zh-CN.resx index c744611a71..263312595e 100644 --- a/src/GameLogic/Properties/PlugInResources.zh-CN.resx +++ b/src/GameLogic/Properties/PlugInResources.zh-CN.resx @@ -1804,4 +1804,10 @@ 处理玩家与幻影寺院盟军物品仓库的交互。盟军将圣物送至此处可获得一分。 幻影寺院幻影物品仓库交互 处理玩家与幻影寺院幻影物品仓库的交互。幻影军将圣物送至此处可获得一分。 + + 在背包中使用的物品 + + + 雕像和护身符等物品在背包中使用。物品使用期间,其加成生效。 + diff --git a/src/GameLogic/Storage.cs b/src/GameLogic/Storage.cs index 5b2ad29d32..479ab73428 100644 --- a/src/GameLogic/Storage.cs +++ b/src/GameLogic/Storage.cs @@ -141,7 +141,7 @@ public virtual async ValueTask AddItemAsync(byte slot, Item item) } /// - public async ValueTask AddItemAsync(Item item) + public virtual async ValueTask AddItemAsync(Item item) { var freeSlot = this.CheckInvSpace(item); if (freeSlot is null) diff --git a/src/GameLogic/Views/Inventory/IInventoryItemUseChangedPlugIn.cs b/src/GameLogic/Views/Inventory/IInventoryItemUseChangedPlugIn.cs new file mode 100644 index 0000000000..26d15550fe --- /dev/null +++ b/src/GameLogic/Views/Inventory/IInventoryItemUseChangedPlugIn.cs @@ -0,0 +1,20 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Views.Inventory; + +using MUnique.OpenMU.DataModel.Entities; + +/// +/// A view plugin which shows whether an item which is used inside the inventory is in use, +/// after the player requested to use it or to stop using it. +/// +public interface IInventoryItemUseChangedPlugIn : IViewPlugIn +{ + /// + /// Shows whether the item is in use. Its durability tells it, see . + /// + /// The item. + ValueTask InventoryItemUseChangedAsync(Item item); +} diff --git a/src/GameServer/MessageHandler/Items/InventoryItemEquipRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/Items/InventoryItemEquipRequestHandlerPlugIn.cs new file mode 100644 index 0000000000..5701e278b6 --- /dev/null +++ b/src/GameServer/MessageHandler/Items/InventoryItemEquipRequestHandlerPlugIn.cs @@ -0,0 +1,44 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.MessageHandler.Items; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.PlayerActions.Items; +using MUnique.OpenMU.GameServer.MessageHandler.MuHelper; +using MUnique.OpenMU.Network.Packets.ClientToServer; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Handler for the request to start or stop using an item inside the inventory, like a figurine or a charm. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.InventoryItemEquipRequestHandlerPlugIn_Name), Description = nameof(PlugInResources.InventoryItemEquipRequestHandlerPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("0CFF0E0C-E744-4F3C-A857-8106BBE76654")] +[MinimumClient(6, 0, ClientLanguage.Invariant)] +[BelongsToGroup(MuHelperGroupHandler.GroupKey)] +internal class InventoryItemEquipRequestHandlerPlugIn : ISubPacketHandlerPlugIn +{ + private readonly UseInventoryItemAction _action = new(); + + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => InventoryItemEquipRequest.SubCode; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + if (packet.Length < InventoryItemEquipRequest.Length) + { + return; + } + + InventoryItemEquipRequest request = packet; + await this._action.SetItemInUseAsync(player, request.InventorySlot, request.Value).ConfigureAwait(false); + } +} diff --git a/src/GameServer/Properties/PlugInResources.Designer.cs b/src/GameServer/Properties/PlugInResources.Designer.cs index da86265900..6bbc6ef617 100644 --- a/src/GameServer/Properties/PlugInResources.Designer.cs +++ b/src/GameServer/Properties/PlugInResources.Designer.cs @@ -8035,5 +8035,17 @@ public static string CastleSiegeLifeStoneStatePlugIn_Name { /// Gets the localized PingHandlerPlugIn_Description text. public static string PingHandlerPlugIn_Description => ResourceManager.GetString("PingHandlerPlugIn_Description", resourceCulture)!; + + /// Gets the localized InventoryItemEquipRequestHandlerPlugIn_Name text. + public static string InventoryItemEquipRequestHandlerPlugIn_Name => ResourceManager.GetString("InventoryItemEquipRequestHandlerPlugIn_Name", resourceCulture)!; + + /// Gets the localized InventoryItemEquipRequestHandlerPlugIn_Description text. + public static string InventoryItemEquipRequestHandlerPlugIn_Description => ResourceManager.GetString("InventoryItemEquipRequestHandlerPlugIn_Description", resourceCulture)!; + + /// Gets the localized InventoryItemUseChangedPlugIn_Name text. + public static string InventoryItemUseChangedPlugIn_Name => ResourceManager.GetString("InventoryItemUseChangedPlugIn_Name", resourceCulture)!; + + /// Gets the localized InventoryItemUseChangedPlugIn_Description text. + public static string InventoryItemUseChangedPlugIn_Description => ResourceManager.GetString("InventoryItemUseChangedPlugIn_Description", resourceCulture)!; } } diff --git a/src/GameServer/Properties/PlugInResources.de.resx b/src/GameServer/Properties/PlugInResources.de.resx index b6beade72a..d0284c8cbc 100644 --- a/src/GameServer/Properties/PlugInResources.de.resx +++ b/src/GameServer/Properties/PlugInResources.de.resx @@ -2673,4 +2673,16 @@ Sendet die Warp-Liste des Servers an den Open-Source-Client, der sie in seinem Warp-Fenster statt der Warp-Liste seiner Datendateien anzeigt. + + Handler für die Benutzung von Gegenständen im Inventar + + + Handler für die Anfrage, einen Gegenstand im Inventar zu benutzen oder dessen Benutzung zu beenden, wie eine Figur oder einen Talisman. + + + Benutzung eines Inventargegenstands geändert + + + Die Standardimplementierung von IInventoryItemUseChangedPlugIn, die alles mit spezifischen Datenpaketen an den Spielclient weiterleitet. + diff --git a/src/GameServer/Properties/PlugInResources.resx b/src/GameServer/Properties/PlugInResources.resx index 62c16f0461..9316d37239 100644 --- a/src/GameServer/Properties/PlugInResources.resx +++ b/src/GameServer/Properties/PlugInResources.resx @@ -2777,4 +2777,16 @@ Sends the warp list of the server to the open source client, which shows it in its warp window instead of the warp list of its data files. + + Inventory item use request handler + + + Handler for the request to start or stop using an item inside the inventory, like a figurine or a charm. + + + Inventory item use changed + + + The default implementation of the IInventoryItemUseChangedPlugIn which is forwarding everything to the game client with specific data packets. + diff --git a/src/GameServer/Properties/PlugInResources.zh-CN.resx b/src/GameServer/Properties/PlugInResources.zh-CN.resx index 5485f6fb41..8c6e76b6f9 100644 --- a/src/GameServer/Properties/PlugInResources.zh-CN.resx +++ b/src/GameServer/Properties/PlugInResources.zh-CN.resx @@ -2532,4 +2532,16 @@ 处理玩家关闭活动结果窗口时发送的幻影寺院奖励请求数据包。 幻影寺院技能请求处理器 处理幻影寺院特殊技能请求数据包。 + + 背包物品使用请求处理程序 + + + 处理在背包中开始或停止使用物品(如雕像或护符)的请求。 + + + 背包物品使用状态已更改 + + + IInventoryItemUseChangedPlugIn的默认实现,通过特定数据包将所有内容转发给游戏客户端。 + diff --git a/src/GameServer/RemoteView/Inventory/InventoryItemUseChangedPlugIn.cs b/src/GameServer/RemoteView/Inventory/InventoryItemUseChangedPlugIn.cs new file mode 100644 index 0000000000..bbdb503d54 --- /dev/null +++ b/src/GameServer/RemoteView/Inventory/InventoryItemUseChangedPlugIn.cs @@ -0,0 +1,36 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.RemoteView.Inventory; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.Views.Inventory; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of the which is forwarding everything to the game client with specific data packets. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.InventoryItemUseChangedPlugIn_Name), Description = nameof(PlugInResources.InventoryItemUseChangedPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("77EB94A6-09DB-424F-B58A-F540D10DD7A6")] +[MinimumClient(6, 0, ClientLanguage.Invariant)] +public class InventoryItemUseChangedPlugIn : IInventoryItemUseChangedPlugIn +{ + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public InventoryItemUseChangedPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask InventoryItemUseChangedAsync(Item item) + { + await this._player.Connection.SendInventoryItemEquipResultAsync(item.ItemSlot, (byte)item.Durability).ConfigureAwait(false); + } +} diff --git a/src/Persistence/Initialization/Properties/ItemNames.Designer.cs b/src/Persistence/Initialization/Properties/ItemNames.Designer.cs index a7cfd7fa65..484e5e96da 100644 --- a/src/Persistence/Initialization/Properties/ItemNames.Designer.cs +++ b/src/Persistence/Initialization/Properties/ItemNames.Designer.cs @@ -5487,5 +5487,47 @@ public static string TalismanOfItemProtection { return ResourceManager.GetString("TalismanOfItemProtection", resourceCulture); } } + + /// Gets HawkFigurine from the configuration name resources. + public static string HawkFigurine { + get { + return ResourceManager.GetString("HawkFigurine", resourceCulture); + } + } + + /// Gets GoatFigurine from the configuration name resources. + public static string GoatFigurine { + get { + return ResourceManager.GetString("GoatFigurine", resourceCulture); + } + } + + /// Gets OakCharm from the configuration name resources. + public static string OakCharm { + get { + return ResourceManager.GetString("OakCharm", resourceCulture); + } + } + + /// Gets MapleCharm from the configuration name resources. + public static string MapleCharm { + get { + return ResourceManager.GetString("MapleCharm", resourceCulture); + } + } + + /// Gets GoldenOakCharm from the configuration name resources. + public static string GoldenOakCharm { + get { + return ResourceManager.GetString("GoldenOakCharm", resourceCulture); + } + } + + /// Gets GoldenMapleCharm from the configuration name resources. + public static string GoldenMapleCharm { + get { + return ResourceManager.GetString("GoldenMapleCharm", resourceCulture); + } + } } } diff --git a/src/Persistence/Initialization/Properties/ItemNames.de.resx b/src/Persistence/Initialization/Properties/ItemNames.de.resx index f73c5ea46f..f29277a4d5 100644 --- a/src/Persistence/Initialization/Properties/ItemNames.de.resx +++ b/src/Persistence/Initialization/Properties/ItemNames.de.resx @@ -2259,4 +2259,22 @@ Talisman des Gegenstandsschutzes + + Falkenfigur + + + Ziegenfigur + + + Eichentalisman + + + Ahorntalisman + + + Goldener Eichentalisman + + + Goldener Ahorntalisman + diff --git a/src/Persistence/Initialization/Properties/ItemNames.resx b/src/Persistence/Initialization/Properties/ItemNames.resx index 31ac86cfdf..e80ffe1188 100644 --- a/src/Persistence/Initialization/Properties/ItemNames.resx +++ b/src/Persistence/Initialization/Properties/ItemNames.resx @@ -2343,4 +2343,22 @@ Talisman of Item Protection + + Hawk Figurine + + + Goat Figurine + + + Oak Charm + + + Maple Charm + + + Golden Oak Charm + + + Golden Maple Charm + \ No newline at end of file diff --git a/src/Persistence/Initialization/Properties/ItemNames.zh-CN.resx b/src/Persistence/Initialization/Properties/ItemNames.zh-CN.resx index 13e847f463..2ac475cf73 100644 --- a/src/Persistence/Initialization/Properties/ItemNames.zh-CN.resx +++ b/src/Persistence/Initialization/Properties/ItemNames.zh-CN.resx @@ -2343,4 +2343,22 @@ 物品保护护身符 + + 鹰雕像 + + + 山羊雕像 + + + 橡树护符 + + + 枫树护符 + + + 黄金橡树护符 + + + 黄金枫树护符 + \ No newline at end of file diff --git a/src/Persistence/Initialization/Properties/PlugInResources.Designer.cs b/src/Persistence/Initialization/Properties/PlugInResources.Designer.cs index c2d5a63c30..28cb78a24a 100644 --- a/src/Persistence/Initialization/Properties/PlugInResources.Designer.cs +++ b/src/Persistence/Initialization/Properties/PlugInResources.Designer.cs @@ -1109,5 +1109,11 @@ public static string DataInitialization095d_Name { /// Gets the localized FixCrywolfGateTerrainUpdatePlugIn_Description text. public static string FixCrywolfGateTerrainUpdatePlugIn_Description => ResourceManager.GetString("FixCrywolfGateTerrainUpdatePlugIn_Description", resourceCulture)!; + /// Gets the localized AddFigurinesAndCharmsUpdatePlugIn_Name text. + public static string AddFigurinesAndCharmsUpdatePlugIn_Name => ResourceManager.GetString("AddFigurinesAndCharmsUpdatePlugIn_Name", resourceCulture)!; + + /// Gets the localized AddFigurinesAndCharmsUpdatePlugIn_Description text. + public static string AddFigurinesAndCharmsUpdatePlugIn_Description => ResourceManager.GetString("AddFigurinesAndCharmsUpdatePlugIn_Description", resourceCulture)!; + } } diff --git a/src/Persistence/Initialization/Properties/PlugInResources.de.resx b/src/Persistence/Initialization/Properties/PlugInResources.de.resx index 7530b3ef21..84881b0b28 100644 --- a/src/Persistence/Initialization/Properties/PlugInResources.de.resx +++ b/src/Persistence/Initialization/Properties/PlugInResources.de.resx @@ -972,4 +972,10 @@ Macht den südlichen Zugang zum Tor der Festung Crywolf im Tal von Loren begehbar, den Spieler außerhalb der Burgbelagerung benutzen. Der nördliche Zugang und alle anderen Felder bleiben unverändert. + + Figuren und Talismane hinzufügen + + + Dieses Update fügt die Falken- und Ziegenfigur sowie die (goldenen) Eichen- und Ahorntalismane hinzu, die ihre Boni geben, während sie im Inventar benutzt werden. + diff --git a/src/Persistence/Initialization/Properties/PlugInResources.resx b/src/Persistence/Initialization/Properties/PlugInResources.resx index ca6308cb0c..8861a66e83 100644 --- a/src/Persistence/Initialization/Properties/PlugInResources.resx +++ b/src/Persistence/Initialization/Properties/PlugInResources.resx @@ -456,4 +456,6 @@ This update adds the Talisman of Guardian, which protects from the death penalty, and the Talisman of Item Protection, which keeps the durability of the equipped items. Fix the Crywolf entrance in Valley of Loren Makes the south entry of the gate to Crywolf Fortress in Valley of Loren walkable, which players use outside of Castle Siege. The north entry and all other tiles are kept. + Add the figurines and charms + This update adds the Hawk and Goat Figurines and the (Golden) Oak and Maple Charms, which give their bonuses while they're used inside the inventory. diff --git a/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx b/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx index 07180accdc..3e3fb53d0e 100644 --- a/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx +++ b/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx @@ -489,4 +489,10 @@ 修正罗兰峡谷通往狼魂要塞的入口 使罗兰峡谷中狼魂要塞传送门的南侧入口可以行走(攻城战以外的时间玩家使用该入口)。北侧入口及其他所有地块保持不变。 + + 添加雕像和护符 + + + 此更新添加鹰雕像、山羊雕像以及(黄金)橡树和枫树护符,它们在背包中使用时提供加成。 + diff --git a/src/Persistence/Initialization/Updates/AddFigurinesAndCharmsUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/AddFigurinesAndCharmsUpdatePlugIn.cs new file mode 100644 index 0000000000..8f6fc65482 --- /dev/null +++ b/src/Persistence/Initialization/Updates/AddFigurinesAndCharmsUpdatePlugIn.cs @@ -0,0 +1,88 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Updates; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix; +using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Items; +using MUnique.OpenMU.PlugIns; + +/// +/// This update adds the figurines and charms, which are used inside the inventory. +/// +[PlugIn] +[Display(Name = nameof(MUnique.OpenMU.Persistence.Initialization.Properties.PlugInResources.AddFigurinesAndCharmsUpdatePlugIn_Name), Description = nameof(MUnique.OpenMU.Persistence.Initialization.Properties.PlugInResources.AddFigurinesAndCharmsUpdatePlugIn_Description), ResourceType = typeof(MUnique.OpenMU.Persistence.Initialization.Properties.PlugInResources))] +[Guid("7EABF432-BC91-48E6-BDD0-5F1B7D7EFBC9")] +public class AddFigurinesAndCharmsUpdatePlugIn : UpdatePlugInBase +{ + /// + /// The items of the products of the cash shop script of the game client, by their product sequence. + /// + private static readonly IReadOnlyDictionary ProductItems = new Dictionary + { + { 459, (13, 128) }, + { 460, (13, 129) }, + { 461, (13, 130) }, + { 462, (13, 132) }, + }; + + /// + /// The plug in name. + /// + internal const string PlugInName = "Add the figurines and charms"; + + /// + /// The plug in description. + /// + internal const string PlugInDescription = "This update adds the Hawk and Goat Figurines and the (Golden) Oak and Maple Charms, which give their bonuses while they're used inside the inventory."; + + /// + public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id; + + /// + public override string Name => PlugInName; + + /// + public override string Description => PlugInDescription; + + /// + public override bool IsMandatory => true; + + /// + public override DateTime CreatedAt => new(2026, 11, 09, 18, 0, 0, DateTimeKind.Utc); + + /// + protected override ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration) + { + new CashShopItems(context, gameConfiguration).CreateFigurinesAndCharms(); + LinkCashShopProducts(gameConfiguration); + return ValueTask.CompletedTask; + } + + /// + /// Links the products of the cash shop to the figurines and charms. The cash shop was created before these items existed, + /// so its products of them have no item definition. Like other packages with products which can't be delivered, + /// their packages are not for sale; the administrator decides when to sell them. + /// + /// The game configuration. + private static void LinkCashShopProducts(GameConfiguration gameConfiguration) + { + if (gameConfiguration.CashShopConfiguration is not { } cashShop + || !CashShopInitializer.UsesClientScript(cashShop)) + { + return; + } + + var products = cashShop.Packages + .SelectMany(package => package.Products) + .Where(product => product.ItemDefinition is null && ProductItems.ContainsKey(product.ProductSequence)); + foreach (var product in products) + { + var (group, number) = ProductItems[product.ProductSequence]; + product.ItemDefinition = gameConfiguration.Items.FirstOrDefault(item => item.Group == group && item.Number == number); + } + } +} diff --git a/src/Persistence/Initialization/VersionSeasonSix/Items/CashShopItems.cs b/src/Persistence/Initialization/VersionSeasonSix/Items/CashShopItems.cs index ae1ec6802d..3c3dbed465 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Items/CashShopItems.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Items/CashShopItems.cs @@ -4,8 +4,11 @@ namespace MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Items; +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel.Attributes; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Persistence.Initialization.Properties; @@ -37,6 +40,7 @@ public override void Initialize() this.CreateItem(14, 163, LocalizedString.FromResource(() => ItemNames.VaultExpansionCertificate)); this.CreateItem(14, 169, LocalizedString.FromResource(() => ItemNames.RageFighterCharacterCard)); this.CreateResetFruits(); + this.CreateFigurinesAndCharms(); } /// @@ -63,6 +67,44 @@ internal IReadOnlyList CreateResetFruits() return fruits; } + /// + /// Creates the figurines and charms, which don't exist yet. They're used inside the inventory + /// with a right click, see . + /// Their durability is 1, so that they're not stackable; the game logic sets the durability of an item + /// to 255 when it's added to the inventory, which tells the client that it's not in use. + /// Their bonuses are the ones which the client shows. The worn horseshoe (13/134) increases the item drop rate, for which there's no attribute yet. + /// + /// The created items. + internal IReadOnlyList CreateFigurinesAndCharms() + { + const byte group = 13; + var items = new List(); + void Create(short number, LocalizedString name, byte height, params (AttributeDefinition Attribute, float Value)[] bonuses) + { + if (this.CreateItem(group, number, name) is not { } item) + { + return; + } + + item.Height = height; + item.Durability = 1; + foreach (var (attribute, value) in bonuses) + { + item.BasePowerUpAttributes.Add(this.CreateItemBasePowerUpDefinition(attribute, value, AggregateType.AddRaw)); + } + + items.Add(item); + } + + Create(128, LocalizedString.FromResource(() => ItemNames.HawkFigurine), 1, (Stats.CriticalDamageBonus, 10)); + Create(129, LocalizedString.FromResource(() => ItemNames.GoatFigurine), 1, (Stats.ExcellentDamageBonus, 10)); + Create(130, LocalizedString.FromResource(() => ItemNames.OakCharm), 1, (Stats.MaximumHealth, 50)); + Create(131, LocalizedString.FromResource(() => ItemNames.MapleCharm), 1, (Stats.MaximumMana, 50)); + Create(132, LocalizedString.FromResource(() => ItemNames.GoldenOakCharm), 2, (Stats.MaximumHealth, 150), (Stats.MaximumShield, 50)); + Create(133, LocalizedString.FromResource(() => ItemNames.GoldenMapleCharm), 2, (Stats.MaximumMana, 150), (Stats.MaximumAbility, 50)); + return items; + } + private ItemDefinition? CreateItem(byte group, short number, LocalizedString name) { if (this.GameConfiguration.Items.Any(item => item.Group == group && item.Number == number)) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/FigurinesAndCharmsDataTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/FigurinesAndCharmsDataTest.cs new file mode 100644 index 0000000000..30cf30c993 --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/FigurinesAndCharmsDataTest.cs @@ -0,0 +1,114 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Tests; + +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.Persistence.Initialization.Updates; +using MUnique.OpenMU.Persistence.InMemory; + +/// +/// Tests the data of the figurines and charms, which are used inside the inventory. +/// +[TestFixture] +internal class FigurinesAndCharmsDataTest +{ + private static readonly short[] Numbers = [128, 129, 130, 131, 132, 133]; + + /// + /// Tests that a new season 6 database contains the items. + /// + [Test] + public async Task NewDatabaseContainsItemsAsync() + { + var (context, gameConfiguration) = await CreateSeason6ConfigurationAsync().ConfigureAwait(false); + using var _ = context; + + AssertItemData(gameConfiguration); + } + + /// + /// Tests that the update adds the items to a database which was created before they existed. + /// Applying it twice doesn't add the items twice. + /// + [Test] + public async Task UpdateAddsItemsToExistingDatabaseAsync() + { + var (context, gameConfiguration) = await CreateSeason6ConfigurationAsync().ConfigureAwait(false); + using var _ = context; + gameConfiguration.Items.Where(IsFigurineOrCharm).ToList().ForEach(item => gameConfiguration.Items.Remove(item)); + + var update = new AddFigurinesAndCharmsUpdatePlugIn(); + await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + + AssertItemData(gameConfiguration); + } + + /// + /// Tests that the update links the products of the cash shop to the items, + /// which have no item definition in a database which was created before the items existed. + /// + [Test] + public async Task UpdateLinksCashShopProductsAsync() + { + var (context, gameConfiguration) = await CreateSeason6ConfigurationAsync().ConfigureAwait(false); + using var _ = context; + var products = gameConfiguration.CashShopConfiguration!.Packages + .SelectMany(package => package.Products) + .Where(product => product.ItemDefinition is { } definition && IsFigurineOrCharm(definition)) + .ToList(); + Assert.That(products.Select(product => product.ProductSequence).Distinct(), Is.EquivalentTo(new[] { 459, 460, 461, 462 })); + products.ForEach(product => product.ItemDefinition = null); + gameConfiguration.Items.Where(IsFigurineOrCharm).ToList().ForEach(item => gameConfiguration.Items.Remove(item)); + + await new AddFigurinesAndCharmsUpdatePlugIn().ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + + Assert.That(products, Has.All.Matches(product => product.ItemDefinition is { Group: 13 } definition && gameConfiguration.Items.Contains(definition))); + Assert.That(products.Where(product => product.ProductSequence == 459).Select(product => product.ItemDefinition!.Number), Has.All.EqualTo(128)); + Assert.That(products.Where(product => product.ProductSequence == 462).Select(product => product.ItemDefinition!.Number), Has.All.EqualTo(132)); + } + + private static bool IsFigurineOrCharm(ItemDefinition item) => item.Group == 13 && Numbers.Contains(item.Number); + + private static void AssertItemData(GameConfiguration gameConfiguration) + { + var items = gameConfiguration.Items.Where(IsFigurineOrCharm).ToList(); + Assert.That(items.Select(item => item.Number), Is.EquivalentTo(Numbers)); + Assert.That(items, Has.All.Matches(item => + item.Durability == 1 + && item.ItemSlot is null + && item.Name.ValueInNeutralLanguage.Length > 0)); + + Assert.That(PowerUp(items, 128, Stats.CriticalDamageBonus), Is.EqualTo(10)); + Assert.That(PowerUp(items, 129, Stats.ExcellentDamageBonus), Is.EqualTo(10)); + Assert.That(PowerUp(items, 130, Stats.MaximumHealth), Is.EqualTo(50)); + Assert.That(PowerUp(items, 131, Stats.MaximumMana), Is.EqualTo(50)); + Assert.That(PowerUp(items, 132, Stats.MaximumHealth), Is.EqualTo(150)); + Assert.That(PowerUp(items, 132, Stats.MaximumShield), Is.EqualTo(50)); + Assert.That(PowerUp(items, 133, Stats.MaximumMana), Is.EqualTo(150)); + Assert.That(PowerUp(items, 133, Stats.MaximumAbility), Is.EqualTo(50)); + } + + private static float? PowerUp(IEnumerable items, short number, AttributeDefinition attribute) + { + return items.Single(item => item.Number == number).BasePowerUpAttributes + .SingleOrDefault(powerUp => powerUp.TargetAttribute == attribute)?.BaseValue; + } + + private static async Task<(IContext Context, GameConfiguration GameConfiguration)> CreateSeason6ConfigurationAsync() + { + var contextProvider = new InMemoryPersistenceContextProvider(); + var dataInitialization = new VersionSeasonSix.DataInitialization(contextProvider, new NullLoggerFactory()); + await dataInitialization.CreateInitialDataAsync(1, true).ConfigureAwait(false); + + var context = contextProvider.CreateNewContext(); + var gameConfiguration = (await context.GetAsync().ConfigureAwait(false)).First(); + return (context, gameConfiguration); + } +} diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemStatProfilesDataTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemStatProfilesDataTest.cs index 70bb95f006..3e8c9d5fe3 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemStatProfilesDataTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemStatProfilesDataTest.cs @@ -204,7 +204,7 @@ public void Season6ItemCountsPerProfile() { "armor: 327", "book: 3", - "default: 243", + "default: 249", "firstTierWing: 4", "helper: 27", "orb: 12", diff --git a/tests/MUnique.OpenMU.Tests/UseInventoryItemActionTest.cs b/tests/MUnique.OpenMU.Tests/UseInventoryItemActionTest.cs new file mode 100644 index 0000000000..a6233aa7a1 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/UseInventoryItemActionTest.cs @@ -0,0 +1,224 @@ +// +// 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.AttributeSystem; +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.PlayerActions.Items; +using MUnique.OpenMU.GameLogic.Views.Inventory; +using BasicModel = MUnique.OpenMU.Persistence.BasicModel; + +/// +/// Tests for the . +/// +[TestFixture] +public class UseInventoryItemActionTest +{ + private const byte InUse = 254; + private const byte NotInUse = 255; + private const float CharmBonus = 50; + + private readonly UseInventoryItemAction _action = new(); + + /// + /// The bonus of a charm applies while it's in use. + /// + [Test] + public async Task BonusAppliesWhileInUseAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var health = player.Attributes![Stats.MaximumHealth]; + var charm = await AddCharmAsync(player).ConfigureAwait(false); + var view = Mock.Get(player.ViewPlugIns.GetPlugIn()!); + + // A new item, which isn't stackable because its definition has a durability of 1, starts without being in use. + Assert.That(charm.Durability, Is.EqualTo(NotInUse)); + + await this._action.SetItemInUseAsync(player, charm.ItemSlot, InUse).ConfigureAwait(false); + Assert.That(charm.Durability, Is.EqualTo(InUse)); + Assert.That(player.Attributes[Stats.MaximumHealth], Is.EqualTo(health + CharmBonus)); + + await this._action.SetItemInUseAsync(player, charm.ItemSlot, NotInUse).ConfigureAwait(false); + Assert.That(charm.Durability, Is.EqualTo(NotInUse)); + Assert.That(player.Attributes[Stats.MaximumHealth], Is.EqualTo(health)); + view.Verify(v => v.InventoryItemUseChangedAsync(charm), Times.Exactly(2)); + } + + /// + /// The same item can only be in use once. + /// + [Test] + public async Task SameItemCanOnlyBeInUseOnceAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var health = player.Attributes![Stats.MaximumHealth]; + var firstCharm = await AddCharmAsync(player).ConfigureAwait(false); + var secondCharm = await AddCharmAsync(player, firstCharm.Definition).ConfigureAwait(false); + + await this._action.SetItemInUseAsync(player, firstCharm.ItemSlot, InUse).ConfigureAwait(false); + await this._action.SetItemInUseAsync(player, secondCharm.ItemSlot, InUse).ConfigureAwait(false); + + Assert.That(secondCharm.Durability, Is.EqualTo(NotInUse)); + Assert.That(player.Attributes[Stats.MaximumHealth], Is.EqualTo(health + CharmBonus)); + } + + /// + /// An item which leaves the inventory, e.g. when it's dropped, stops being in use. + /// When it's added again, it's not in use, so that it can't add its bonus twice. + /// + [Test] + public async Task RemovingStopsUseAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var health = player.Attributes![Stats.MaximumHealth]; + var charm = await AddCharmAsync(player).ConfigureAwait(false); + await this._action.SetItemInUseAsync(player, charm.ItemSlot, InUse).ConfigureAwait(false); + + var slot = charm.ItemSlot; + await player.Inventory!.RemoveItemAsync(charm).ConfigureAwait(false); + Assert.That(charm.Durability, Is.EqualTo(NotInUse)); + Assert.That(player.Attributes[Stats.MaximumHealth], Is.EqualTo(health)); + + Assert.That(await player.Inventory.AddItemAsync(slot, charm).ConfigureAwait(false), Is.True); + Assert.That(charm.Durability, Is.EqualTo(NotInUse)); + Assert.That(player.Attributes[Stats.MaximumHealth], Is.EqualTo(health)); + } + + /// + /// An item which is moved inside the inventory stays in use, and keeps its bonus during the move, + /// so that the current health isn't limited by a lower maximum health. + /// + [Test] + public async Task MovingInsideInventoryKeepsUseAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var health = player.Attributes![Stats.MaximumHealth]; + var charm = await AddCharmAsync(player).ConfigureAwait(false); + await this._action.SetItemInUseAsync(player, charm.ItemSlot, InUse).ConfigureAwait(false); + player.Attributes[Stats.CurrentHealth] = health + CharmBonus; + var toSlot = (byte)(charm.ItemSlot + 1); + + await new MoveItemAction().MoveItemAsync(player, charm.ItemSlot, Storages.Inventory, toSlot, Storages.Inventory).ConfigureAwait(false); + + Assert.That(charm.ItemSlot, Is.EqualTo(toSlot)); + Assert.That(charm.Durability, Is.EqualTo(InUse)); + Assert.That(player.Attributes[Stats.MaximumHealth], Is.EqualTo(health + CharmBonus)); + Assert.That(player.Attributes[Stats.CurrentHealth], Is.EqualTo(health + CharmBonus)); + } + + /// + /// When an item is added which is in use, while another item of the same kind is in use, + /// it's not in use anymore. + /// + [Test] + public async Task AddedDuplicateIsNotInUseAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var health = player.Attributes![Stats.MaximumHealth]; + var firstCharm = await AddCharmAsync(player).ConfigureAwait(false); + await this._action.SetItemInUseAsync(player, firstCharm.ItemSlot, InUse).ConfigureAwait(false); + + var secondCharm = player.PersistenceContext.CreateNew(); + secondCharm.Definition = firstCharm.Definition; + secondCharm.Durability = InUse; + Assert.That(await player.Inventory!.AddItemAsync(secondCharm).ConfigureAwait(false), Is.True); + + Assert.That(secondCharm.Durability, Is.EqualTo(NotInUse)); + Assert.That(player.Attributes[Stats.MaximumHealth], Is.EqualTo(health + CharmBonus)); + } + + /// + /// The items which can be used inside the inventory are configured at the feature plug-in. + /// + [Test] + public async Task UsableItemsAreConfiguredAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var health = player.Attributes![Stats.MaximumHealth]; + var charm = await AddCharmAsync(player).ConfigureAwait(false); + var plugIn = player.GameContext.FeaturePlugIns.GetPlugIn(); + Assert.That(plugIn, Is.Not.Null); + plugIn!.Configuration = new InventoryItemUseConfiguration { Items = [] }; + + await this._action.SetItemInUseAsync(player, charm.ItemSlot, InUse).ConfigureAwait(false); + + Assert.That(charm.Durability, Is.EqualTo(NotInUse)); + Assert.That(player.Attributes[Stats.MaximumHealth], Is.EqualTo(health)); + } + + /// + /// An item which is moved out of the inventory, e.g. into the vault, stops being in use. + /// + [Test] + public async Task MovingOutOfInventoryStopsUseAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var health = player.Attributes![Stats.MaximumHealth]; + var charm = await AddCharmAsync(player).ConfigureAwait(false); + await this._action.SetItemInUseAsync(player, charm.ItemSlot, InUse).ConfigureAwait(false); + var vaultStorage = new Mock(); + vaultStorage.Setup(i => i.Items).Returns(new List()); + player.Vault = new Storage(InventoryConstants.WarehouseSize, vaultStorage.Object); + player.IsVaultLocked = false; + player.OpenedNpc = new NonPlayerCharacter(null!, new MonsterDefinition { NpcWindow = NpcWindow.VaultStorage }, null!); + Assert.That(await player.PlayerState.TryAdvanceToAsync(PlayerState.NpcDialogOpened).ConfigureAwait(false), Is.True); + + await new MoveItemAction().MoveItemAsync(player, charm.ItemSlot, Storages.Inventory, 0, Storages.Vault).ConfigureAwait(false); + + Assert.That(player.Vault!.Items, Does.Contain(charm)); + Assert.That(charm.Durability, Is.EqualTo(NotInUse)); + Assert.That(player.Attributes[Stats.MaximumHealth], Is.EqualTo(health)); + } + + /// + /// Other items and other durability values can't be used inside the inventory. + /// + [Test] + public async Task OnlyCharmsCanBeUsedAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var health = player.Attributes![Stats.MaximumHealth]; + var charm = await AddCharmAsync(player).ConfigureAwait(false); + var otherDefinition = new BasicModel.ItemDefinition { Group = 13, Number = 20, Width = 1, Height = 1, Durability = 255 }; + otherDefinition.BasePowerUpAttributes.Add(new BasicModel.ItemBasePowerUpDefinition { TargetAttribute = Stats.MaximumHealth, BaseValue = CharmBonus, AggregateType = AggregateType.AddRaw }); + var other = await AddCharmAsync(player, otherDefinition).ConfigureAwait(false); + + await this._action.SetItemInUseAsync(player, other.ItemSlot, InUse).ConfigureAwait(false); + await this._action.SetItemInUseAsync(player, charm.ItemSlot, 100).ConfigureAwait(false); + + Assert.That(other.Durability, Is.EqualTo(NotInUse)); + Assert.That(charm.Durability, Is.EqualTo(NotInUse)); + Assert.That(player.Attributes[Stats.MaximumHealth], Is.EqualTo(health)); + } + + private static async ValueTask CreatePlayerAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + player.GameContext.FeaturePlugIns.AddPlugIn(new InventoryItemUseFeaturePlugIn(), true); + return player; + } + + private static async ValueTask AddCharmAsync(Player player, ItemDefinition? definition = null) + { + if (definition is null) + { + definition = new BasicModel.ItemDefinition { Group = 13, Number = 130, Width = 1, Height = 1, Durability = 1 }; + definition.BasePowerUpAttributes.Add(new BasicModel.ItemBasePowerUpDefinition { TargetAttribute = Stats.MaximumHealth, BaseValue = CharmBonus, AggregateType = AggregateType.AddRaw }); + } + + var item = player.PersistenceContext.CreateNew(); + item.Definition = definition; + item.Durability = definition.Durability; + Assert.That(await player.Inventory!.AddItemAsync(item).ConfigureAwait(false), Is.True); + return item; + } +}