diff --git a/docs-website/docs/server-features/lucky-coins.md b/docs-website/docs/server-features/lucky-coins.md new file mode 100644 index 0000000000..af25814b57 --- /dev/null +++ b/docs-website/docs/server-features/lucky-coins.md @@ -0,0 +1,38 @@ +--- +title: Lucky coins +sidebar_label: Lucky coins +sidebar_position: 6 +description: Registering lucky coins at Delgado and exchanging them for items. +--- + +Delgado, an NPC in Devias, collects lucky coins. When a player talks to him, +the game client offers two dialogs: + +* **Lucky coin registration**: each click registers one lucky coin of the + inventory. The coin is used up, and the count of the registered coins of the + character goes up. The dialog shows this count. In the original game, the + registered coins took part in a lottery, which isn't part of the server; the + count is kept in the character attribute *Registered Lucky Coins*, so that a + game master can see it. +* **Lucky coin exchange**: the player exchanges 10, 20 or 30 coins for an item. + The coins can be spread over several stacks. When the inventory has no space + for the item, the coins are kept. + +Lucky coins are stacked, up to 255 coins per stack. They don't drop from +monsters by default; add them to a drop item group to make them obtainable +(see [Item drops](../admin-panel/item-drops.md)). + +## Settings + +The plugin **Lucky coin event** is configured on the +[Plugins](../admin-panel/plugins.md) page. When it's deactivated, the coins +can't be registered or exchanged. + +* **Rewards** (default: none): the possible items of the exchange. Each reward + has the count of coins (10, 20 or 30), the group and number of the item and + its level. One of the rewards for the exchanged count of coins is chosen + randomly. + +The rewards of the original game are not known, so there are none by default. +Without a reward for a count of coins, the exchange of that count is refused, +and the client says that there are not enough coins. diff --git a/docs-website/sidebars.js b/docs-website/sidebars.js index 21a4fe4991..3bb71b4fdc 100644 --- a/docs-website/sidebars.js +++ b/docs-website/sidebars.js @@ -68,6 +68,7 @@ const sidebars = { "server-features/cash-shop", "server-features/discord", "server-features/player-shop-currencies", + "server-features/lucky-coins", "server-features/waiting-items", ], }, diff --git a/docs/Packets/C1-BF-0B-LuckyCoinCountResponse_by-server.md b/docs/Packets/C1-BF-0B-LuckyCoinCountResponse_by-server.md new file mode 100644 index 0000000000..5d48ef0884 --- /dev/null +++ b/docs/Packets/C1-BF-0B-LuckyCoinCountResponse_by-server.md @@ -0,0 +1,19 @@ +# C1 BF 0B - LuckyCoinCountResponse (by server) + +## Is sent when + +After the player opened the lucky coin registration dialog of the NPC Delgado, which requests the count of the registered coins. + +## Causes the following actions on the client side + +The client shows the count of the registered lucky coins in the dialog. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) | +| 1 | 1 | Byte | 8 | Packet header - length of the packet | +| 2 | 1 | Byte | 0xBF | Packet header - packet type identifier | +| 3 | 1 | Byte | 0x0B | Packet header - sub packet type identifier | +| 4 | 4 | IntegerLittleEndian | | Count | \ No newline at end of file diff --git a/docs/Packets/C1-BF-0C-LuckyCoinRegistrationResponse_by-server.md b/docs/Packets/C1-BF-0C-LuckyCoinRegistrationResponse_by-server.md new file mode 100644 index 0000000000..ac6051d8e2 --- /dev/null +++ b/docs/Packets/C1-BF-0C-LuckyCoinRegistrationResponse_by-server.md @@ -0,0 +1,30 @@ +# C1 BF 0C - LuckyCoinRegistrationResponse (by server) + +## Is sent when + +After the player requested to register a lucky coin at the NPC Delgado. + +## Causes the following actions on the client side + +The client unlocks the register button. When the coin was registered, it shows the new count of the registered coins; otherwise, it shows a message. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) | +| 1 | 1 | Byte | 12 | Packet header - length of the packet | +| 2 | 1 | Byte | 0xBF | Packet header - packet type identifier | +| 3 | 1 | Byte | 0x0C | Packet header - sub packet type identifier | +| 4 | 1 | LuckyCoinRegistrationResult | | Result | +| 8 | 4 | IntegerLittleEndian | | Count | + +### LuckyCoinRegistrationResult Enum + +Defines the result of the lucky coin registration request. + +| Value | Name | Description | +|-------|------|-------------| +| 0 | NotEnoughItems | The player has no lucky coin in the inventory. | +| 1 | Registered | The lucky coin was registered. | +| 100 | AlreadyApplied | The account already applied, and it can only apply once. | \ No newline at end of file diff --git a/docs/Packets/C1-BF-0D-LuckyCoinExchangeResponse_by-server.md b/docs/Packets/C1-BF-0D-LuckyCoinExchangeResponse_by-server.md new file mode 100644 index 0000000000..a3e7a9c07a --- /dev/null +++ b/docs/Packets/C1-BF-0D-LuckyCoinExchangeResponse_by-server.md @@ -0,0 +1,29 @@ +# C1 BF 0D - LuckyCoinExchangeResponse (by server) + +## Is sent when + +After the player requested to exchange lucky coins for an item at the NPC Delgado. + +## Causes the following actions on the client side + +The client unlocks the exchange buttons and shows a message about the result. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) | +| 1 | 1 | Byte | 5 | Packet header - length of the packet | +| 2 | 1 | Byte | 0xBF | Packet header - packet type identifier | +| 3 | 1 | Byte | 0x0D | Packet header - sub packet type identifier | +| 4 | 1 | LuckyCoinExchangeResult | | Result | + +### LuckyCoinExchangeResult Enum + +Defines the result of the lucky coin exchange request. + +| Value | Name | Description | +|-------|------|-------------| +| 0 | NotEnoughItems | The player doesn't have enough lucky coins in the inventory. | +| 1 | Exchanged | The lucky coins were exchanged for an item. | +| 2 | InventoryFull | The inventory has not enough space for the item. The client asks for a free space of 2 x 4. | \ No newline at end of file diff --git a/docs/Packets/ServerToClient.md b/docs/Packets/ServerToClient.md index 4ef37621ae..f998bc783f 100644 --- a/docs/Packets/ServerToClient.md +++ b/docs/Packets/ServerToClient.md @@ -241,8 +241,11 @@ * [C1 BF 08 - IllusionTempleHolyItemRelics (by server)](C1-BF-08-IllusionTempleHolyItemRelics_by-server.md) * [C1 BF 09 - IllusionTempleEventState (by server)](C1-BF-09-IllusionTempleEventState_by-server.md) * [C1 BF 0A - ChainLightningHitInfo (by server)](C1-BF-0A-ChainLightningHitInfo_by-server.md) + * [C1 BF 0B - LuckyCoinCountResponse (by server)](C1-BF-0B-LuckyCoinCountResponse_by-server.md) * [C1 BF 0B - LuckyCoinCount (by server)](C1-BF-0B-LuckyCoinCount_by-server.md) + * [C1 BF 0C - LuckyCoinRegistrationResponse (by server)](C1-BF-0C-LuckyCoinRegistrationResponse_by-server.md) * [C1 BF 0C - LuckyCoinRegistrationResult (by server)](C1-BF-0C-LuckyCoinRegistrationResult_by-server.md) + * [C1 BF 0D - LuckyCoinExchangeResponse (by server)](C1-BF-0D-LuckyCoinExchangeResponse_by-server.md) * [C1 BF 0D - LuckyCoinExchangeResult (by server)](C1-BF-0D-LuckyCoinExchangeResult_by-server.md) * [C1 BF 0E - DoppelgangerEnterResult (by server)](C1-BF-0E-DoppelgangerEnterResult_by-server.md) * [C1 BF 0F - DoppelgangerMonsterPosition (by server)](C1-BF-0F-DoppelgangerMonsterPosition_by-server.md) diff --git a/docs/Progress.md b/docs/Progress.md index 7997328639..7cf3a86e96 100644 --- a/docs/Progress.md +++ b/docs/Progress.md @@ -87,6 +87,7 @@ complexity and effort). Complexity 0 means we wont implement it. | *RequestGuildMarkCastleOwner* | 0xB902 | 100% | 1 | Entering the hunting zone (0xB905) is implemented | | *JewelMix* | 0xBC | 100% | 4 | | | *CrywolfGroup* | 0xBD | 100% | 10 | | +| *LuckyCoin* | 0xBF0B-0xBF0D | 100% | 3 | Registration and exchange at Delgado | | *IllusionTempleGroup* | 0xBF | 100% | 10 | Team-based PvP mini game: relic pickup/delivery, scoring, skill points, special skills, rewards | | FriendListRequest | 0xC0 | 0% | 0 | Not needed, friend list is sent automatically | | *FriendAdd* | 0xC1 | 100% | 2 | | diff --git a/src/GameLogic/Attributes/Stats.cs b/src/GameLogic/Attributes/Stats.cs index 27fc839925..89759fab83 100644 --- a/src/GameLogic/Attributes/Stats.cs +++ b/src/GameLogic/Attributes/Stats.cs @@ -163,6 +163,11 @@ public class Stats /// public static AttributeDefinition TotalRegisteredRenas { get; } = new(new Guid("56C2D3B4-4F5A-6B7C-8D9E-0F1A2B3C4D5E"), "Total Registered Renas", "Total registered items (all-time counter)"); + /// + /// Gets the attribute definition of the count of the lucky coins which a character registered at the NPC Delgado. + /// + public static AttributeDefinition RegisteredLuckyCoins { get; } = new(new Guid("7D4A0CDF-124F-4CFF-89FF-357A2001B041"), "Registered Lucky Coins", "The count of the lucky coins which were registered at the NPC Delgado."); + /// /// Gets the zen amount rate attribute definition. /// diff --git a/src/GameLogic/ItemConstants.cs b/src/GameLogic/ItemConstants.cs index cd4ddd5a78..7263d9d269 100644 --- a/src/GameLogic/ItemConstants.cs +++ b/src/GameLogic/ItemConstants.cs @@ -269,6 +269,11 @@ public class ItemConstants /// public static ItemIdentifier MoonstonePendant => new(38, 13); + /// + /// Gets the identifier for the lucky coin (group 14, number 100), which is registered and exchanged at the NPC Delgado. + /// + public static ItemIdentifier LuckyCoin => new(100, 14); + /// /// Gets all scrolls. /// diff --git a/src/GameLogic/LuckyCoins/LuckyCoinConfiguration.cs b/src/GameLogic/LuckyCoins/LuckyCoinConfiguration.cs new file mode 100644 index 0000000000..9182b1023a --- /dev/null +++ b/src/GameLogic/LuckyCoins/LuckyCoinConfiguration.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.LuckyCoins; + +/// +/// The configuration of the lucky coin exchange at the NPC Delgado. +/// +public class LuckyCoinConfiguration +{ + /// + /// Gets or sets the possible rewards of the exchange. When a player exchanges coins, + /// one of the rewards for that count of coins is chosen randomly. + /// + /// + /// The rewards of the original game are server data which isn't known, so there are none by default. + /// Without a reward for a count of coins, the exchange of that count isn't possible. + /// + public IList Rewards { get; set; } = new List(); +} diff --git a/src/GameLogic/LuckyCoins/LuckyCoinFeaturePlugIn.cs b/src/GameLogic/LuckyCoins/LuckyCoinFeaturePlugIn.cs new file mode 100644 index 0000000000..936d27ac18 --- /dev/null +++ b/src/GameLogic/LuckyCoins/LuckyCoinFeaturePlugIn.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.LuckyCoins; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.PlugIns; + +/// +/// The lucky coin event of the NPC Delgado: players register lucky coins, or exchange them for items. +/// When it's deactivated, the players can't register or exchange lucky coins. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.LuckyCoinFeaturePlugIn_Name), Description = nameof(PlugInResources.LuckyCoinFeaturePlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("3D6F9337-6A79-4F89-B995-4B7E722E63D1")] +public class LuckyCoinFeaturePlugIn : IFeaturePlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration +{ + /// + public LuckyCoinConfiguration? Configuration { get; set; } + + /// + /// Gets the configuration of the lucky coin event of the game context. + /// + /// The game context. + /// The configuration; null, if the lucky coin event is deactivated. + public static LuckyCoinConfiguration? GetActiveConfiguration(IGameContext gameContext) + { + return gameContext.FeaturePlugIns.GetPlugIn() is { } plugIn + ? plugIn.Configuration ?? new LuckyCoinConfiguration() + : null; + } + + /// + public object CreateDefaultConfig() + { + return new LuckyCoinConfiguration(); + } +} diff --git a/src/GameLogic/LuckyCoins/LuckyCoinReward.cs b/src/GameLogic/LuckyCoins/LuckyCoinReward.cs new file mode 100644 index 0000000000..811647357f --- /dev/null +++ b/src/GameLogic/LuckyCoins/LuckyCoinReward.cs @@ -0,0 +1,38 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.LuckyCoins; + +/// +/// A possible reward of the lucky coin exchange at the NPC Delgado. +/// +public class LuckyCoinReward +{ + /// + /// Gets or sets the count of the lucky coins which are exchanged for this reward. + /// The game client offers to exchange 10, 20 or 30 coins. + /// + public int CoinCount { get; set; } + + /// + /// Gets or sets the group of the reward item. + /// + public byte ItemGroup { get; set; } + + /// + /// Gets or sets the number of the reward item. + /// + public short ItemNumber { get; set; } + + /// + /// Gets or sets the level of the reward item. + /// + public byte ItemLevel { get; set; } + + /// + public override string ToString() + { + return $"{this.CoinCount} coins: {this.ItemGroup}/{this.ItemNumber} +{this.ItemLevel}"; + } +} diff --git a/src/GameLogic/PlayerActions/Items/LuckyCoinActions.cs b/src/GameLogic/PlayerActions/Items/LuckyCoinActions.cs new file mode 100644 index 0000000000..316da62e61 --- /dev/null +++ b/src/GameLogic/PlayerActions/Items/LuckyCoinActions.cs @@ -0,0 +1,189 @@ +// +// 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.AttributeSystem; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.LuckyCoins; +using MUnique.OpenMU.GameLogic.Views.Inventory; +using MUnique.OpenMU.GameLogic.Views.NPC; + +/// +/// The actions of the lucky coin dialogs of the NPC Delgado: showing the count of the registered coins, +/// registering a coin and exchanging coins for an item. +/// +/// +/// A registered coin is used up and counted at the character. In the original game, the registered coins took part in a lottery, +/// which isn't part of the game server. +/// +public class LuckyCoinActions +{ + /// + /// Shows the count of the lucky coins which the character registered. + /// + /// The player. + public async ValueTask ShowRegisteredCountAsync(Player player) + { + if (!CanUseLuckyCoinDialog(player)) + { + return; + } + + var count = GetRegisteredCount(player); + await player.InvokeViewPlugInAsync(p => p.ShowRegisteredCountAsync(count)).ConfigureAwait(false); + } + + /// + /// Registers one lucky coin of the inventory. + /// + /// The player. + public async ValueTask RegisterAsync(Player player) + { + if (!CanUseLuckyCoinDialog(player) || player.SelectedCharacter is not { } character) + { + return; + } + + if (GetCoins(player).FirstOrDefault() is not { } coin) + { + await player.InvokeViewPlugInAsync(p => p.ShowRegistrationResultAsync(LuckyCoinRegistrationResult.NotEnoughItems, GetRegisteredCount(player))).ConfigureAwait(false); + return; + } + + await RemoveCoinsAsync(player, [coin], 1).ConfigureAwait(false); + var registeredCount = GetRegisteredCount(player) + 1; + GetOrCreateRegisteredCoinsAttribute(player, character).Value = registeredCount; + player.Logger.LogInformation("Player {player} registered a lucky coin, now {count} coins are registered.", player, registeredCount); + await player.InvokeViewPlugInAsync(p => p.ShowRegistrationResultAsync(LuckyCoinRegistrationResult.Registered, registeredCount)).ConfigureAwait(false); + } + + /// + /// Exchanges lucky coins of the inventory for a random item of the configured rewards. + /// + /// The player. + /// The count of the coins which should be exchanged. + public async ValueTask ExchangeAsync(Player player, int coinCount) + { + if (!CanUseLuckyCoinDialog(player) + || LuckyCoinFeaturePlugIn.GetActiveConfiguration(player.GameContext) is not { } configuration + || player.Inventory is not { } inventory) + { + return; + } + + var result = await TryExchangeAsync(player, inventory, configuration, coinCount).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.ShowExchangeResultAsync(result)).ConfigureAwait(false); + } + + private static bool CanUseLuckyCoinDialog(Player player) + { + return player.OpenedNpc?.Definition.NpcWindow == NpcWindow.DelgadoLuckyCoinRegistration + && LuckyCoinFeaturePlugIn.GetActiveConfiguration(player.GameContext) is not null; + } + + private static List GetCoins(Player player) + { + return player.Inventory?.Items + .Where(item => item.Definition is { } definition + && definition.Group == ItemConstants.LuckyCoin.Group + && definition.Number == ItemConstants.LuckyCoin.Number + && item.Durability > 0) + .ToList() ?? []; + } + + private static int GetRegisteredCount(Player player) + { + return (int)(player.SelectedCharacter?.Attributes.FirstOrDefault(a => a.Definition?.Id == Stats.RegisteredLuckyCoins.Id)?.Value ?? 0); + } + + private static StatAttribute GetOrCreateRegisteredCoinsAttribute(Player player, Character character) + { + if (character.Attributes.FirstOrDefault(a => a.Definition?.Id == Stats.RegisteredLuckyCoins.Id) is { } attribute) + { + return attribute; + } + + var definition = player.GameContext.Configuration.Attributes?.FirstOrDefault(a => a.Id == Stats.RegisteredLuckyCoins.Id) ?? Stats.RegisteredLuckyCoins; + attribute = player.PersistenceContext.CreateNew(definition, 0); + character.Attributes.Add(attribute); + return attribute; + } + + /// + /// Removes the count of coins from the coin stacks. A stack which gets empty is removed from the inventory. + /// + private static async ValueTask RemoveCoinsAsync(Player player, IEnumerable coins, int count) + { + var remaining = count; + foreach (var coin in coins) + { + if (remaining <= 0) + { + break; + } + + var taken = Math.Min(remaining, (int)coin.Durability); + remaining -= taken; + coin.Durability -= taken; + if (coin.Durability <= 0) + { + await player.DestroyInventoryItemAsync(coin).ConfigureAwait(false); + } + else + { + await player.InvokeViewPlugInAsync(p => p.ItemDurabilityChangedAsync(coin, false)).ConfigureAwait(false); + } + } + } + + private static async ValueTask TryExchangeAsync(Player player, IInventoryStorage inventory, LuckyCoinConfiguration configuration, int coinCount) + { + var coins = GetCoins(player); + if (coinCount <= 0 || coins.Sum(coin => (int)coin.Durability) < coinCount) + { + return LuckyCoinExchangeResult.NotEnoughItems; + } + + var rewards = configuration.Rewards.Where(reward => reward.CoinCount == coinCount).ToList(); + if (rewards.Count == 0) + { + player.Logger.LogWarning("Player {player} requested to exchange {coinCount} lucky coins, but there is no reward configured for it.", player, coinCount); + return LuckyCoinExchangeResult.NotEnoughItems; + } + + var reward = rewards[Rand.NextInt(0, rewards.Count)]; + if (player.GameContext.Configuration.Items.FirstOrDefault(item => item.Group == reward.ItemGroup && item.Number == reward.ItemNumber) is not { } itemDefinition) + { + player.Logger.LogWarning("The lucky coin reward {reward} refers to an item which doesn't exist.", reward); + return LuckyCoinExchangeResult.NotEnoughItems; + } + + var item = player.PersistenceContext.CreateNew(); + item.Definition = itemDefinition; + item.Level = Math.Min(reward.ItemLevel, itemDefinition.MaximumItemLevel); + item.Durability = item.IsStackable() ? 1 : itemDefinition.Durability; + if (inventory.CheckInvSpace(item) is null) + { + await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false); + return LuckyCoinExchangeResult.InventoryFull; + } + + // The reward is added before the coins are removed, so that the player keeps the coins when it fails. + if (!await inventory.AddItemAsync(item).ConfigureAwait(false)) + { + await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false); + player.Logger.LogError("The lucky coin reward {item} of player {player} couldn't be added to the inventory.", item, player); + return LuckyCoinExchangeResult.InventoryFull; + } + + await RemoveCoinsAsync(player, coins, coinCount).ConfigureAwait(false); + + player.Logger.LogInformation("Player {player} exchanged {coinCount} lucky coins for {item}.", player, coinCount, item); + await player.InvokeViewPlugInAsync(p => p.ItemAppearAsync(item)).ConfigureAwait(false); + return LuckyCoinExchangeResult.Exchanged; + } +} diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 473c8cc222..970ce07138 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -7802,6 +7802,14 @@ public static string WorldChatChatCommandPlugIn_Name { } /// + /// Looks up a localized string similar to Lucky coin event. + /// + public static string LuckyCoinFeaturePlugIn_Name { + get { + return ResourceManager.GetString("LuckyCoinFeaturePlugIn_Name", resourceCulture); + } + } + /// Looks up a localized string similar to Gladiator's Honor consume handler. /// public static string GladiatorsHonorConsumeHandlerPlugIn_Name { @@ -7811,6 +7819,14 @@ public static string GladiatorsHonorConsumeHandlerPlugIn_Name { } /// + /// Looks up a localized string similar to Players can register lucky coins at the NPC Delgado, or exchange 10, 20 or 30 of them for an item. The rewards of the exchange are configured here.. + /// + public static string LuckyCoinFeaturePlugIn_Description { + get { + return ResourceManager.GetString("LuckyCoinFeaturePlugIn_Description", resourceCulture); + } + } + /// Looks up a localized string similar to Applies the effect of the Gladiator's Honor, which can only be used in Vulcanus.. /// public static string GladiatorsHonorConsumeHandlerPlugIn_Description { diff --git a/src/GameLogic/Properties/PlugInResources.de.resx b/src/GameLogic/Properties/PlugInResources.de.resx index fc47f51508..00c01b3582 100644 --- a/src/GameLogic/Properties/PlugInResources.de.resx +++ b/src/GameLogic/Properties/PlugInResources.de.resx @@ -2016,6 +2016,12 @@ Teilt dem Client beim Betreten des Spiels mit, welche Warps der Spieler im Warp-Fenster auswählen kann. + + Glücksmünzen-Event + + + Spieler können beim NPC Delgado Glücksmünzen registrieren oder 10, 20 oder 30 davon gegen einen Gegenstand tauschen. Die Belohnungen des Tauschs werden hier konfiguriert. + Verbrauch der Ehre des Gladiators diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 06edf22b15..577a9aa606 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -2061,6 +2061,12 @@ Tells the client which warps the player can select in the warp window, when the player enters the game. + + Lucky coin event + + + Players can register lucky coins at the NPC Delgado, or exchange 10, 20 or 30 of them for an item. The rewards of the exchange are configured here. + Gladiator's Honor consume handler diff --git a/src/GameLogic/Properties/PlugInResources.zh-CN.resx b/src/GameLogic/Properties/PlugInResources.zh-CN.resx index f9e9186b3a..a64888b890 100644 --- a/src/GameLogic/Properties/PlugInResources.zh-CN.resx +++ b/src/GameLogic/Properties/PlugInResources.zh-CN.resx @@ -1804,6 +1804,12 @@ 处理玩家与幻影寺院盟军物品仓库的交互。盟军将圣物送至此处可获得一分。 幻影寺院幻影物品仓库交互 处理玩家与幻影寺院幻影物品仓库的交互。幻影军将圣物送至此处可获得一分。 + + 幸运币活动 + + + 玩家可以在NPC德尔加多处登记幸运币,或用10、20或30枚幸运币兑换一件物品。兑换的奖励在此配置。 + 角斗士的荣耀使用 diff --git a/src/GameLogic/Views/NPC/ILuckyCoinViewPlugIn.cs b/src/GameLogic/Views/NPC/ILuckyCoinViewPlugIn.cs new file mode 100644 index 0000000000..b8df1d31cf --- /dev/null +++ b/src/GameLogic/Views/NPC/ILuckyCoinViewPlugIn.cs @@ -0,0 +1,68 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Views.NPC; + +/// +/// The result of a lucky coin registration at the NPC Delgado. +/// +public enum LuckyCoinRegistrationResult +{ + /// + /// The player has no lucky coin in the inventory. + /// + NotEnoughItems, + + /// + /// The lucky coin was registered. + /// + Registered, +} + +/// +/// The result of a lucky coin exchange at the NPC Delgado. +/// +public enum LuckyCoinExchangeResult +{ + /// + /// The player doesn't have enough lucky coins in the inventory, + /// or no reward is configured for the requested count of coins. + /// + NotEnoughItems, + + /// + /// The lucky coins were exchanged for an item. + /// + Exchanged, + + /// + /// The inventory has not enough space for the item. + /// + InventoryFull, +} + +/// +/// A view plugin which shows the results of the lucky coin dialogs of the NPC Delgado. +/// +public interface ILuckyCoinViewPlugIn : IViewPlugIn +{ + /// + /// Shows the count of the lucky coins which the character registered. + /// + /// The count of the registered lucky coins. + ValueTask ShowRegisteredCountAsync(int count); + + /// + /// Shows the result of a lucky coin registration. + /// + /// The result. + /// The count of the registered lucky coins after the registration. + ValueTask ShowRegistrationResultAsync(LuckyCoinRegistrationResult result, int count); + + /// + /// Shows the result of a lucky coin exchange. + /// + /// The result. + ValueTask ShowExchangeResultAsync(LuckyCoinExchangeResult result); +} diff --git a/src/GameServer/MessageHandler/Items/LuckyCoinCountRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/Items/LuckyCoinCountRequestHandlerPlugIn.cs new file mode 100644 index 0000000000..9761687134 --- /dev/null +++ b/src/GameServer/MessageHandler/Items/LuckyCoinCountRequestHandlerPlugIn.cs @@ -0,0 +1,43 @@ +// +// 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 of the count of the registered lucky coins, sent when the lucky coin registration dialog of the NPC Delgado opens. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.LuckyCoinCountRequestHandlerPlugIn_Name), Description = nameof(PlugInResources.LuckyCoinCountRequestHandlerPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("E8340144-4DC4-4F7A-8F2B-530EE9853742")] +[MinimumClient(6, 0, ClientLanguage.Invariant)] +[BelongsToGroup(MuHelperGroupHandler.GroupKey)] +internal class LuckyCoinCountRequestHandlerPlugIn : ISubPacketHandlerPlugIn +{ + private readonly LuckyCoinActions _actions = new(); + + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => LuckyCoinCountRequest.SubCode; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + if (packet.Length < LuckyCoinCountRequest.Length) + { + return; + } + + await this._actions.ShowRegisteredCountAsync(player).ConfigureAwait(false); + } +} diff --git a/src/GameServer/MessageHandler/Items/LuckyCoinExchangeRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/Items/LuckyCoinExchangeRequestHandlerPlugIn.cs new file mode 100644 index 0000000000..a11c9bdfb5 --- /dev/null +++ b/src/GameServer/MessageHandler/Items/LuckyCoinExchangeRequestHandlerPlugIn.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 exchange lucky coins for an item at the NPC Delgado. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.LuckyCoinExchangeRequestHandlerPlugIn_Name), Description = nameof(PlugInResources.LuckyCoinExchangeRequestHandlerPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("8998E334-1714-4F4F-9467-D4DD76915BCE")] +[MinimumClient(6, 0, ClientLanguage.Invariant)] +[BelongsToGroup(MuHelperGroupHandler.GroupKey)] +internal class LuckyCoinExchangeRequestHandlerPlugIn : ISubPacketHandlerPlugIn +{ + private readonly LuckyCoinActions _actions = new(); + + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => LuckyCoinExchangeRequest.SubCode; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + if (packet.Length < LuckyCoinExchangeRequest.Length) + { + return; + } + + LuckyCoinExchangeRequest request = packet; + await this._actions.ExchangeAsync(player, (int)request.CoinCount).ConfigureAwait(false); + } +} diff --git a/src/GameServer/MessageHandler/Items/LuckyCoinRegistrationRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/Items/LuckyCoinRegistrationRequestHandlerPlugIn.cs new file mode 100644 index 0000000000..423b900cc8 --- /dev/null +++ b/src/GameServer/MessageHandler/Items/LuckyCoinRegistrationRequestHandlerPlugIn.cs @@ -0,0 +1,43 @@ +// +// 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 register a lucky coin at the NPC Delgado. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.LuckyCoinRegistrationRequestHandlerPlugIn_Name), Description = nameof(PlugInResources.LuckyCoinRegistrationRequestHandlerPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("14052A86-62E5-4037-9E17-6775A05E2613")] +[MinimumClient(6, 0, ClientLanguage.Invariant)] +[BelongsToGroup(MuHelperGroupHandler.GroupKey)] +internal class LuckyCoinRegistrationRequestHandlerPlugIn : ISubPacketHandlerPlugIn +{ + private readonly LuckyCoinActions _actions = new(); + + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => LuckyCoinRegistrationRequest.SubCode; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + if (packet.Length < LuckyCoinRegistrationRequest.Length) + { + return; + } + + await this._actions.RegisterAsync(player).ConfigureAwait(false); + } +} diff --git a/src/GameServer/Properties/PlugInResources.Designer.cs b/src/GameServer/Properties/PlugInResources.Designer.cs index da86265900..1666c39d84 100644 --- a/src/GameServer/Properties/PlugInResources.Designer.cs +++ b/src/GameServer/Properties/PlugInResources.Designer.cs @@ -8035,5 +8035,29 @@ public static string CastleSiegeLifeStoneStatePlugIn_Name { /// Gets the localized PingHandlerPlugIn_Description text. public static string PingHandlerPlugIn_Description => ResourceManager.GetString("PingHandlerPlugIn_Description", resourceCulture)!; + + /// Gets the localized LuckyCoinViewPlugIn_Name text. + public static string LuckyCoinViewPlugIn_Name => ResourceManager.GetString("LuckyCoinViewPlugIn_Name", resourceCulture)!; + + /// Gets the localized LuckyCoinViewPlugIn_Description text. + public static string LuckyCoinViewPlugIn_Description => ResourceManager.GetString("LuckyCoinViewPlugIn_Description", resourceCulture)!; + + /// Gets the localized LuckyCoinCountRequestHandlerPlugIn_Name text. + public static string LuckyCoinCountRequestHandlerPlugIn_Name => ResourceManager.GetString("LuckyCoinCountRequestHandlerPlugIn_Name", resourceCulture)!; + + /// Gets the localized LuckyCoinCountRequestHandlerPlugIn_Description text. + public static string LuckyCoinCountRequestHandlerPlugIn_Description => ResourceManager.GetString("LuckyCoinCountRequestHandlerPlugIn_Description", resourceCulture)!; + + /// Gets the localized LuckyCoinRegistrationRequestHandlerPlugIn_Name text. + public static string LuckyCoinRegistrationRequestHandlerPlugIn_Name => ResourceManager.GetString("LuckyCoinRegistrationRequestHandlerPlugIn_Name", resourceCulture)!; + + /// Gets the localized LuckyCoinRegistrationRequestHandlerPlugIn_Description text. + public static string LuckyCoinRegistrationRequestHandlerPlugIn_Description => ResourceManager.GetString("LuckyCoinRegistrationRequestHandlerPlugIn_Description", resourceCulture)!; + + /// Gets the localized LuckyCoinExchangeRequestHandlerPlugIn_Name text. + public static string LuckyCoinExchangeRequestHandlerPlugIn_Name => ResourceManager.GetString("LuckyCoinExchangeRequestHandlerPlugIn_Name", resourceCulture)!; + + /// Gets the localized LuckyCoinExchangeRequestHandlerPlugIn_Description text. + public static string LuckyCoinExchangeRequestHandlerPlugIn_Description => ResourceManager.GetString("LuckyCoinExchangeRequestHandlerPlugIn_Description", resourceCulture)!; } } diff --git a/src/GameServer/Properties/PlugInResources.de.resx b/src/GameServer/Properties/PlugInResources.de.resx index b6beade72a..bc6cf9dc93 100644 --- a/src/GameServer/Properties/PlugInResources.de.resx +++ b/src/GameServer/Properties/PlugInResources.de.resx @@ -2673,4 +2673,28 @@ Sendet die Warp-Liste des Servers an den Open-Source-Client, der sie in seinem Warp-Fenster statt der Warp-Liste seiner Datendateien anzeigt. + + Glücksmünzen-Ansicht + + + Die Standardimplementierung von ILuckyCoinViewPlugIn, die alles mit spezifischen Datenpaketen an den Spielclient weiterleitet. + + + Handler für die Anzahl der Glücksmünzen + + + Handler für die Anfrage der Anzahl der registrierten Glücksmünzen, die beim Öffnen des Registrierungsdialogs des NPC Delgado gesendet wird. + + + Handler für die Registrierung von Glücksmünzen + + + Handler für die Anfrage, eine Glücksmünze beim NPC Delgado zu registrieren. + + + Handler für den Tausch von Glücksmünzen + + + Handler für die Anfrage, Glücksmünzen beim NPC Delgado gegen einen Gegenstand zu tauschen. + diff --git a/src/GameServer/Properties/PlugInResources.resx b/src/GameServer/Properties/PlugInResources.resx index 62c16f0461..2312144dee 100644 --- a/src/GameServer/Properties/PlugInResources.resx +++ b/src/GameServer/Properties/PlugInResources.resx @@ -2777,4 +2777,28 @@ 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. + + Lucky coin view + + + The default implementation of the ILuckyCoinViewPlugIn which is forwarding everything to the game client with specific data packets. + + + Lucky coin count request handler + + + Handler for the request of the count of the registered lucky coins, sent when the lucky coin registration dialog of the NPC Delgado opens. + + + Lucky coin registration request handler + + + Handler for the request to register a lucky coin at the NPC Delgado. + + + Lucky coin exchange request handler + + + Handler for the request to exchange lucky coins for an item at the NPC Delgado. + diff --git a/src/GameServer/Properties/PlugInResources.zh-CN.resx b/src/GameServer/Properties/PlugInResources.zh-CN.resx index 5485f6fb41..7d6f91aa02 100644 --- a/src/GameServer/Properties/PlugInResources.zh-CN.resx +++ b/src/GameServer/Properties/PlugInResources.zh-CN.resx @@ -2532,4 +2532,28 @@ 处理玩家关闭活动结果窗口时发送的幻影寺院奖励请求数据包。 幻影寺院技能请求处理器 处理幻影寺院特殊技能请求数据包。 + + 幸运币视图 + + + ILuckyCoinViewPlugIn的默认实现,通过特定数据包将所有内容转发给游戏客户端。 + + + 幸运币数量请求处理程序 + + + 处理已登记幸运币数量的请求,该请求在打开NPC德尔加多的幸运币登记对话框时发送。 + + + 幸运币登记请求处理程序 + + + 处理在NPC德尔加多处登记幸运币的请求。 + + + 幸运币兑换请求处理程序 + + + 处理在NPC德尔加多处用幸运币兑换物品的请求。 + diff --git a/src/GameServer/RemoteView/NPC/LuckyCoinViewPlugIn.cs b/src/GameServer/RemoteView/NPC/LuckyCoinViewPlugIn.cs new file mode 100644 index 0000000000..c98e0c2017 --- /dev/null +++ b/src/GameServer/RemoteView/NPC/LuckyCoinViewPlugIn.cs @@ -0,0 +1,58 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.RemoteView.NPC; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.Views.NPC; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; +using LuckyCoinExchangeResult = MUnique.OpenMU.GameLogic.Views.NPC.LuckyCoinExchangeResult; +using LuckyCoinRegistrationResult = MUnique.OpenMU.GameLogic.Views.NPC.LuckyCoinRegistrationResult; + +/// +/// The default implementation of the which is forwarding everything to the game client with specific data packets. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.LuckyCoinViewPlugIn_Name), Description = nameof(PlugInResources.LuckyCoinViewPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("E7D90509-EBD1-4685-8C5A-015316C2AF4B")] +[MinimumClient(6, 0, ClientLanguage.Invariant)] +public class LuckyCoinViewPlugIn : ILuckyCoinViewPlugIn +{ + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public LuckyCoinViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask ShowRegisteredCountAsync(int count) + { + await this._player.Connection.SendLuckyCoinCountResponseAsync((uint)Math.Max(count, 0)).ConfigureAwait(false); + } + + /// + public async ValueTask ShowRegistrationResultAsync(LuckyCoinRegistrationResult result, int count) + { + var packetResult = result == LuckyCoinRegistrationResult.Registered + ? LuckyCoinRegistrationResponse.LuckyCoinRegistrationResult.Registered + : LuckyCoinRegistrationResponse.LuckyCoinRegistrationResult.NotEnoughItems; + await this._player.Connection.SendLuckyCoinRegistrationResponseAsync(packetResult, (uint)Math.Max(count, 0)).ConfigureAwait(false); + } + + /// + public async ValueTask ShowExchangeResultAsync(LuckyCoinExchangeResult result) + { + var packetResult = result switch + { + LuckyCoinExchangeResult.Exchanged => LuckyCoinExchangeResponse.LuckyCoinExchangeResult.Exchanged, + LuckyCoinExchangeResult.InventoryFull => LuckyCoinExchangeResponse.LuckyCoinExchangeResult.InventoryFull, + _ => LuckyCoinExchangeResponse.LuckyCoinExchangeResult.NotEnoughItems, + }; + await this._player.Connection.SendLuckyCoinExchangeResponseAsync(packetResult).ConfigureAwait(false); + } +} diff --git a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs index 34f8b7eb24..608599a389 100644 --- a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs +++ b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs @@ -5953,6 +5953,92 @@ int WritePacket() await connection.SendAsync(WritePacket).ConfigureAwait(false); } + /// + /// Sends a to this connection. + /// + /// The connection. + /// The count. + /// + /// Is sent by the server when: After the player opened the lucky coin registration dialog of the NPC Delgado, which requests the count of the registered coins. + /// Causes reaction on client side: The client shows the count of the registered lucky coins in the dialog. + /// + public static async ValueTask SendLuckyCoinCountResponseAsync(this IConnection? connection, uint @count) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = LuckyCoinCountResponseRef.Length; + var packet = new LuckyCoinCountResponseRef(connection.Output.GetSpan(length)[..length]); + packet.Count = @count; + + return packet.Header.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + + /// + /// Sends a to this connection. + /// + /// The connection. + /// The result. + /// The count. + /// + /// Is sent by the server when: After the player requested to register a lucky coin at the NPC Delgado. + /// Causes reaction on client side: The client unlocks the register button. When the coin was registered, it shows the new count of the registered coins; otherwise, it shows a message. + /// + public static async ValueTask SendLuckyCoinRegistrationResponseAsync(this IConnection? connection, LuckyCoinRegistrationResponse.LuckyCoinRegistrationResult @result, uint @count) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = LuckyCoinRegistrationResponseRef.Length; + var packet = new LuckyCoinRegistrationResponseRef(connection.Output.GetSpan(length)[..length]); + packet.Result = @result; + packet.Count = @count; + + return packet.Header.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + + /// + /// Sends a to this connection. + /// + /// The connection. + /// The result. + /// + /// Is sent by the server when: After the player requested to exchange lucky coins for an item at the NPC Delgado. + /// Causes reaction on client side: The client unlocks the exchange buttons and shows a message about the result. + /// + public static async ValueTask SendLuckyCoinExchangeResponseAsync(this IConnection? connection, LuckyCoinExchangeResponse.LuckyCoinExchangeResult @result) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = LuckyCoinExchangeResponseRef.Length; + var packet = new LuckyCoinExchangeResponseRef(connection.Output.GetSpan(length)[..length]); + packet.Result = @result; + + return packet.Header.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + /// /// Sends a to this connection. /// diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs index 2ca1664994..8ce48409c6 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs @@ -28730,6 +28730,315 @@ public ushort TargetId } +/// +/// Is sent by the server when: After the player opened the lucky coin registration dialog of the NPC Delgado, which requests the count of the registered coins. +/// Causes reaction on client side: The client shows the count of the registered lucky coins in the dialog. +/// +public readonly struct LuckyCoinCountResponse +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public LuckyCoinCountResponse(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private LuckyCoinCountResponse(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xBF; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x0B; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 8; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCode Header => new (this._data); + + /// + /// Gets or sets the count. + /// + public uint Count + { + get => ReadUInt32LittleEndian(this._data.Span[4..]); + set => WriteUInt32LittleEndian(this._data.Span[4..], value); + } + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator LuckyCoinCountResponse(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(LuckyCoinCountResponse packet) => packet._data; +} + + +/// +/// Is sent by the server when: After the player requested to register a lucky coin at the NPC Delgado. +/// Causes reaction on client side: The client unlocks the register button. When the coin was registered, it shows the new count of the registered coins; otherwise, it shows a message. +/// +public readonly struct LuckyCoinRegistrationResponse +{ + /// + /// Defines the result of the lucky coin registration request. + /// + public enum LuckyCoinRegistrationResult + { + /// + /// The player has no lucky coin in the inventory. + /// + NotEnoughItems = 0, + + /// + /// The lucky coin was registered. + /// + Registered = 1, + + /// + /// The account already applied, and it can only apply once. + /// + AlreadyApplied = 100, + } + + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public LuckyCoinRegistrationResponse(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private LuckyCoinRegistrationResponse(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xBF; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x0C; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 12; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCode Header => new (this._data); + + /// + /// Gets or sets the result. + /// + public LuckyCoinRegistrationResponse.LuckyCoinRegistrationResult Result + { + get => (LuckyCoinRegistrationResult)this._data.Span[4]; + set => this._data.Span[4] = (byte)value; + } + + /// + /// Gets or sets the count. + /// + public uint Count + { + get => ReadUInt32LittleEndian(this._data.Span[8..]); + set => WriteUInt32LittleEndian(this._data.Span[8..], value); + } + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator LuckyCoinRegistrationResponse(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(LuckyCoinRegistrationResponse packet) => packet._data; +} + + +/// +/// Is sent by the server when: After the player requested to exchange lucky coins for an item at the NPC Delgado. +/// Causes reaction on client side: The client unlocks the exchange buttons and shows a message about the result. +/// +public readonly struct LuckyCoinExchangeResponse +{ + /// + /// Defines the result of the lucky coin exchange request. + /// + public enum LuckyCoinExchangeResult + { + /// + /// The player doesn't have enough lucky coins in the inventory. + /// + NotEnoughItems = 0, + + /// + /// The lucky coins were exchanged for an item. + /// + Exchanged = 1, + + /// + /// The inventory has not enough space for the item. The client asks for a free space of 2 x 4. + /// + InventoryFull = 2, + } + + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public LuckyCoinExchangeResponse(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private LuckyCoinExchangeResponse(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xBF; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x0D; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 5; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCode Header => new (this._data); + + /// + /// Gets or sets the result. + /// + public LuckyCoinExchangeResponse.LuckyCoinExchangeResult Result + { + get => (LuckyCoinExchangeResult)this._data.Span[4]; + set => this._data.Span[4] = (byte)value; + } + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator LuckyCoinExchangeResponse(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(LuckyCoinExchangeResponse packet) => packet._data; +} + + /// /// Is sent by the server when: The player requested to enter the doppelganger event through the NPC Lugard. /// Causes reaction on client side: On failure, the client locks the enter button of the doppelganger entry dialog and may show a message. diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml index 3f91944658..256d9f72f6 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -10471,6 +10471,110 @@ + + C1HeaderWithSubCode + BF + 0B + LuckyCoinCountResponse + 8 + ServerToClient + After the player opened the lucky coin registration dialog of the NPC Delgado, which requests the count of the registered coins. + The client shows the count of the registered lucky coins in the dialog. + + + 4 + IntegerLittleEndian + Count + + + + + C1HeaderWithSubCode + BF + 0C + LuckyCoinRegistrationResponse + 12 + ServerToClient + After the player requested to register a lucky coin at the NPC Delgado. + The client unlocks the register button. When the coin was registered, it shows the new count of the registered coins; otherwise, it shows a message. + + + 4 + Enum + LuckyCoinRegistrationResult + Result + + + 8 + IntegerLittleEndian + Count + + + + + LuckyCoinRegistrationResult + Defines the result of the lucky coin registration request. + + + NotEnoughItems + The player has no lucky coin in the inventory. + 0 + + + Registered + The lucky coin was registered. + 1 + + + AlreadyApplied + The account already applied, and it can only apply once. + 100 + + + + + + + C1HeaderWithSubCode + BF + 0D + LuckyCoinExchangeResponse + 5 + ServerToClient + After the player requested to exchange lucky coins for an item at the NPC Delgado. + The client unlocks the exchange buttons and shows a message about the result. + + + 4 + Enum + LuckyCoinExchangeResult + Result + + + + + LuckyCoinExchangeResult + Defines the result of the lucky coin exchange request. + + + NotEnoughItems + The player doesn't have enough lucky coins in the inventory. + 0 + + + Exchanged + The lucky coins were exchanged for an item. + 1 + + + InventoryFull + The inventory has not enough space for the item. The client asks for a free space of 2 x 4. + 2 + + + + + C1HeaderWithSubCode BF diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs index c83a0741de..2cec89cb6b 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs @@ -27252,6 +27252,273 @@ public ushort TargetId } +/// +/// Is sent by the server when: After the player opened the lucky coin registration dialog of the NPC Delgado, which requests the count of the registered coins. +/// Causes reaction on client side: The client shows the count of the registered lucky coins in the dialog. +/// +public readonly ref struct LuckyCoinCountResponseRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public LuckyCoinCountResponseRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private LuckyCoinCountResponseRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xBF; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x0B; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 8; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCodeRef Header => new (this._data); + + /// + /// Gets or sets the count. + /// + public uint Count + { + get => ReadUInt32LittleEndian(this._data[4..]); + set => WriteUInt32LittleEndian(this._data[4..], value); + } + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator LuckyCoinCountResponseRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(LuckyCoinCountResponseRef packet) => packet._data; +} + + +/// +/// Is sent by the server when: After the player requested to register a lucky coin at the NPC Delgado. +/// Causes reaction on client side: The client unlocks the register button. When the coin was registered, it shows the new count of the registered coins; otherwise, it shows a message. +/// +public readonly ref struct LuckyCoinRegistrationResponseRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public LuckyCoinRegistrationResponseRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private LuckyCoinRegistrationResponseRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xBF; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x0C; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 12; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCodeRef Header => new (this._data); + + /// + /// Gets or sets the result. + /// + public LuckyCoinRegistrationResponse.LuckyCoinRegistrationResult Result + { + get => (LuckyCoinRegistrationResponse.LuckyCoinRegistrationResult)this._data[4]; + set => this._data[4] = (byte)value; + } + + /// + /// Gets or sets the count. + /// + public uint Count + { + get => ReadUInt32LittleEndian(this._data[8..]); + set => WriteUInt32LittleEndian(this._data[8..], value); + } + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator LuckyCoinRegistrationResponseRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(LuckyCoinRegistrationResponseRef packet) => packet._data; +} + + +/// +/// Is sent by the server when: After the player requested to exchange lucky coins for an item at the NPC Delgado. +/// Causes reaction on client side: The client unlocks the exchange buttons and shows a message about the result. +/// +public readonly ref struct LuckyCoinExchangeResponseRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public LuckyCoinExchangeResponseRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private LuckyCoinExchangeResponseRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xBF; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x0D; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 5; + + /// + /// Gets the header of this packet. + /// + public C1HeaderWithSubCodeRef Header => new (this._data); + + /// + /// Gets or sets the result. + /// + public LuckyCoinExchangeResponse.LuckyCoinExchangeResult Result + { + get => (LuckyCoinExchangeResponse.LuckyCoinExchangeResult)this._data[4]; + set => this._data[4] = (byte)value; + } + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator LuckyCoinExchangeResponseRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(LuckyCoinExchangeResponseRef packet) => packet._data; +} + + /// /// Is sent by the server when: The player requested to enter the doppelganger event through the NPC Lugard. /// Causes reaction on client side: On failure, the client locks the enter button of the doppelganger entry dialog and may show a message. diff --git a/src/Persistence/Initialization/Properties/ItemNames.Designer.cs b/src/Persistence/Initialization/Properties/ItemNames.Designer.cs index 4585f0f10c..f845d52682 100644 --- a/src/Persistence/Initialization/Properties/ItemNames.Designer.cs +++ b/src/Persistence/Initialization/Properties/ItemNames.Designer.cs @@ -5488,6 +5488,13 @@ public static string TalismanOfItemProtection { } } + /// Gets LuckyCoin from the configuration name resources. + public static string LuckyCoin { + get { + return ResourceManager.GetString("LuckyCoin", resourceCulture); + } + } + /// Gets GladiatorsHonor from the configuration name resources. public static string GladiatorsHonor { get { diff --git a/src/Persistence/Initialization/Properties/ItemNames.de.resx b/src/Persistence/Initialization/Properties/ItemNames.de.resx index eb5cc25be7..9ed4b96a03 100644 --- a/src/Persistence/Initialization/Properties/ItemNames.de.resx +++ b/src/Persistence/Initialization/Properties/ItemNames.de.resx @@ -2259,6 +2259,9 @@ Talisman des Gegenstandsschutzes + + Glücksmünze + Ehre des Gladiators diff --git a/src/Persistence/Initialization/Properties/ItemNames.resx b/src/Persistence/Initialization/Properties/ItemNames.resx index 3ab25f3868..c72a2f14b5 100644 --- a/src/Persistence/Initialization/Properties/ItemNames.resx +++ b/src/Persistence/Initialization/Properties/ItemNames.resx @@ -2343,6 +2343,9 @@ Talisman of Item Protection + + Lucky Coin + Gladiator's Honor diff --git a/src/Persistence/Initialization/Properties/ItemNames.zh-CN.resx b/src/Persistence/Initialization/Properties/ItemNames.zh-CN.resx index 81dc265056..60b63e344a 100644 --- a/src/Persistence/Initialization/Properties/ItemNames.zh-CN.resx +++ b/src/Persistence/Initialization/Properties/ItemNames.zh-CN.resx @@ -2343,6 +2343,9 @@ 物品保护护身符 + + 幸运币 + 角斗士的荣耀 diff --git a/src/Persistence/Initialization/Properties/PlugInResources.Designer.cs b/src/Persistence/Initialization/Properties/PlugInResources.Designer.cs index de813af443..7b578feb82 100644 --- a/src/Persistence/Initialization/Properties/PlugInResources.Designer.cs +++ b/src/Persistence/Initialization/Properties/PlugInResources.Designer.cs @@ -1109,6 +1109,12 @@ public static string DataInitialization095d_Name { /// Gets the localized FixCrywolfGateTerrainUpdatePlugIn_Description text. public static string FixCrywolfGateTerrainUpdatePlugIn_Description => ResourceManager.GetString("FixCrywolfGateTerrainUpdatePlugIn_Description", resourceCulture)!; + /// Gets the localized AddLuckyCoinUpdatePlugIn_Name text. + public static string AddLuckyCoinUpdatePlugIn_Name => ResourceManager.GetString("AddLuckyCoinUpdatePlugIn_Name", resourceCulture)!; + + /// Gets the localized AddLuckyCoinUpdatePlugIn_Description text. + public static string AddLuckyCoinUpdatePlugIn_Description => ResourceManager.GetString("AddLuckyCoinUpdatePlugIn_Description", resourceCulture)!; + /// Gets the localized AddGladiatorsHonorUpdatePlugIn_Name text. public static string AddGladiatorsHonorUpdatePlugIn_Name => ResourceManager.GetString("AddGladiatorsHonorUpdatePlugIn_Name", resourceCulture)!; diff --git a/src/Persistence/Initialization/Properties/PlugInResources.de.resx b/src/Persistence/Initialization/Properties/PlugInResources.de.resx index b50aed0394..d4ecda61a9 100644 --- a/src/Persistence/Initialization/Properties/PlugInResources.de.resx +++ b/src/Persistence/Initialization/Properties/PlugInResources.de.resx @@ -972,6 +972,12 @@ 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. + + Glücksmünze hinzufügen + + + Dieses Update fügt die Glücksmünze hinzu, die beim NPC Delgado in Devias registriert oder gegen Gegenstände getauscht wird, und öffnet seinen Glücksmünzen-Dialog. + Ehre des Gladiators hinzufügen diff --git a/src/Persistence/Initialization/Properties/PlugInResources.resx b/src/Persistence/Initialization/Properties/PlugInResources.resx index 06d034594f..194b5e7e18 100644 --- a/src/Persistence/Initialization/Properties/PlugInResources.resx +++ b/src/Persistence/Initialization/Properties/PlugInResources.resx @@ -456,6 +456,8 @@ 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 lucky coin + This update adds the lucky coin, which is registered or exchanged for items at the NPC Delgado in Devias, and opens his lucky coin dialog. Add the Gladiator's Honor diff --git a/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx b/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx index e2f8b37c47..cc438fd497 100644 --- a/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx +++ b/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx @@ -489,6 +489,12 @@ 修正罗兰峡谷通往狼魂要塞的入口 使罗兰峡谷中狼魂要塞传送门的南侧入口可以行走(攻城战以外的时间玩家使用该入口)。北侧入口及其他所有地块保持不变。 + + 添加幸运币 + + + 此更新添加幸运币,可在德维亚斯的NPC德尔加多处登记或兑换物品,并打开他的幸运币对话框。 + 添加角斗士的荣耀 diff --git a/src/Persistence/Initialization/Updates/AddLuckyCoinUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/AddLuckyCoinUpdatePlugIn.cs new file mode 100644 index 0000000000..75dbaac0c9 --- /dev/null +++ b/src/Persistence/Initialization/Updates/AddLuckyCoinUpdatePlugIn.cs @@ -0,0 +1,70 @@ +// +// 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.AttributeSystem; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Items; +using MUnique.OpenMU.PlugIns; + +/// +/// This update adds the lucky coin, opens the lucky coin dialog at the NPC Delgado, +/// and adds the attribute which counts the registered coins of a character. +/// +[PlugIn] +[Display(Name = nameof(MUnique.OpenMU.Persistence.Initialization.Properties.PlugInResources.AddLuckyCoinUpdatePlugIn_Name), Description = nameof(MUnique.OpenMU.Persistence.Initialization.Properties.PlugInResources.AddLuckyCoinUpdatePlugIn_Description), ResourceType = typeof(MUnique.OpenMU.Persistence.Initialization.Properties.PlugInResources))] +[Guid("DC8775D2-78BD-4DB3-AB51-FE84B9DDF7A2")] +public class AddLuckyCoinUpdatePlugIn : UpdatePlugInBase +{ + /// + /// The plug in name. + /// + internal const string PlugInName = "Add the lucky coin"; + + /// + /// The plug in description. + /// + internal const string PlugInDescription = "This update adds the lucky coin, which is registered or exchanged for items at the NPC Delgado in Devias, and opens his lucky coin dialog."; + + /// + /// The number of the NPC Delgado. + /// + private const short DelgadoNumber = 478; + + /// + 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, 14, 0, 0, DateTimeKind.Utc); + + /// + protected override ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration) + { + if (gameConfiguration.Attributes.All(a => a.Id != Stats.RegisteredLuckyCoins.Id)) + { + gameConfiguration.Attributes.Add(context.CreateNew(Stats.RegisteredLuckyCoins.Id, Stats.RegisteredLuckyCoins.Designation, Stats.RegisteredLuckyCoins.Description)); + } + + new Misc(context, gameConfiguration).CreateLuckyCoin(); + + if (gameConfiguration.Monsters.FirstOrDefault(m => m.Number == DelgadoNumber) is { NpcWindow: NpcWindow.Undefined } delgado) + { + delgado.NpcWindow = NpcWindow.DelgadoLuckyCoinRegistration; + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Persistence/Initialization/VersionSeasonSix/Items/Misc.cs b/src/Persistence/Initialization/VersionSeasonSix/Items/Misc.cs index 24430502a2..655c22deef 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Items/Misc.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Items/Misc.cs @@ -31,6 +31,33 @@ public override void Initialize() this.CreateLostMap(); this.CreateSymbolOfKundun(); this.CreateRena(); + this.CreateLuckyCoin(); + } + + /// + /// Creates the lucky coin, if it doesn't exist yet. It's registered and exchanged at the NPC Delgado, + /// see . Like in the client, the coins are stacked, + /// so the durability is the count of the coins. + /// + /// The created item, or null, if it already existed. + internal ItemDefinition? CreateLuckyCoin() + { + if (this.GameConfiguration.Items.Any(item => item.Group == 14 && item.Number == 100)) + { + return null; + } + + var itemDefinition = this.Context.CreateNew(); + itemDefinition.Name = LocalizedString.FromResource(() => ItemNames.LuckyCoin); + itemDefinition.Number = 100; + itemDefinition.Group = 14; + itemDefinition.DropsFromMonsters = false; + itemDefinition.Durability = 255; + itemDefinition.Width = 1; + itemDefinition.Height = 1; + itemDefinition.SetGuid(itemDefinition.Group, itemDefinition.Number); + this.GameConfiguration.Items.Add(itemDefinition); + return itemDefinition; } private void CreateLostMap() diff --git a/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs b/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs index b2992d0f7a..04eace1984 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs @@ -719,6 +719,7 @@ public override void Initialize() def.Number = 478; def.Designation = LocalizedString.FromResource(() => MonsterNames.DelgadoLuckyCoins); def.ObjectKind = NpcObjectKind.PassiveNpc; + def.NpcWindow = NpcWindow.DelgadoLuckyCoinRegistration; this.GameConfiguration.Monsters.Add(def); def.SetGuid(def.Number); } diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/LuckyCoinDataTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/LuckyCoinDataTest.cs new file mode 100644 index 0000000000..e97f1161c2 --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/LuckyCoinDataTest.cs @@ -0,0 +1,92 @@ +// +// 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.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 lucky coin and the NPC Delgado. +/// +[TestFixture] +internal class LuckyCoinDataTest +{ + private const short DelgadoNumber = 478; + + /// + /// Tests that a new season 6 database contains the lucky coin, the attribute of the registered coins, + /// and that Delgado opens the lucky coin dialog. + /// + [Test] + public async Task NewDatabaseContainsLuckyCoinAsync() + { + var (context, gameConfiguration) = await CreateSeason6ConfigurationAsync().ConfigureAwait(false); + using var _ = context; + + AssertLuckyCoinData(gameConfiguration); + } + + /// + /// Tests that the update adds the lucky coin data to a database which was created before it existed. + /// Applying it twice doesn't add anything twice. + /// + [Test] + public async Task UpdateAddsLuckyCoinToExistingDatabaseAsync() + { + var (context, gameConfiguration) = await CreateSeason6ConfigurationAsync().ConfigureAwait(false); + using var _ = context; + gameConfiguration.Items.Where(IsLuckyCoin).ToList().ForEach(item => gameConfiguration.Items.Remove(item)); + gameConfiguration.Attributes.Where(a => a.Id == Stats.RegisteredLuckyCoins.Id).ToList().ForEach(a => gameConfiguration.Attributes.Remove(a)); + gameConfiguration.Monsters.Single(m => m.Number == DelgadoNumber).NpcWindow = NpcWindow.Undefined; + + var update = new AddLuckyCoinUpdatePlugIn(); + await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + + AssertLuckyCoinData(gameConfiguration); + } + + /// + /// Tests that the update keeps another window which a server owner configured for Delgado. + /// + [Test] + public async Task UpdateKeepsCustomizedNpcWindowAsync() + { + var (context, gameConfiguration) = await CreateSeason6ConfigurationAsync().ConfigureAwait(false); + using var _ = context; + var delgado = gameConfiguration.Monsters.Single(m => m.Number == DelgadoNumber); + delgado.NpcWindow = NpcWindow.Merchant; + + await new AddLuckyCoinUpdatePlugIn().ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + + Assert.That(delgado.NpcWindow, Is.EqualTo(NpcWindow.Merchant)); + } + + private static bool IsLuckyCoin(ItemDefinition item) => item is { Group: 14, Number: 100 }; + + private static void AssertLuckyCoinData(GameConfiguration gameConfiguration) + { + var coin = gameConfiguration.Items.Single(IsLuckyCoin); + Assert.That(coin.Durability, Is.EqualTo(255)); + Assert.That(coin.Name.ValueInNeutralLanguage, Is.EqualTo("Lucky Coin")); + Assert.That(gameConfiguration.Attributes.Count(a => a.Id == Stats.RegisteredLuckyCoins.Id), Is.EqualTo(1)); + Assert.That(gameConfiguration.Monsters.Single(m => m.Number == DelgadoNumber).NpcWindow, Is.EqualTo(NpcWindow.DelgadoLuckyCoinRegistration)); + } + + 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.Tests/LuckyCoinActionsTest.cs b/tests/MUnique.OpenMU.Tests/LuckyCoinActionsTest.cs new file mode 100644 index 0000000000..29d6048355 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/LuckyCoinActionsTest.cs @@ -0,0 +1,147 @@ +// +// 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; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.LuckyCoins; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.PlayerActions.Items; +using MUnique.OpenMU.GameLogic.Views.NPC; + +/// +/// Tests for the . +/// +[TestFixture] +public class LuckyCoinActionsTest +{ + private readonly LuckyCoinActions _actions = new(); + + /// + /// A registration uses up one coin of a stack and counts it at the character. + /// + [Test] + public async Task RegisterUsesUpOneCoinAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var coins = await AddCoinsAsync(player, 3).ConfigureAwait(false); + var view = Mock.Get(player.ViewPlugIns.GetPlugIn()!); + + await this._actions.RegisterAsync(player).ConfigureAwait(false); + await this._actions.RegisterAsync(player).ConfigureAwait(false); + + Assert.That(coins.Durability, Is.EqualTo(1)); + view.Verify(v => v.ShowRegistrationResultAsync(LuckyCoinRegistrationResult.Registered, 1), Times.Once); + view.Verify(v => v.ShowRegistrationResultAsync(LuckyCoinRegistrationResult.Registered, 2), Times.Once); + Assert.That(player.SelectedCharacter!.Attributes.Single(a => a.Definition!.Id == Stats.RegisteredLuckyCoins.Id).Value, Is.EqualTo(2)); + + await this._actions.ShowRegisteredCountAsync(player).ConfigureAwait(false); + view.Verify(v => v.ShowRegisteredCountAsync(2), Times.Once); + } + + /// + /// Without a coin, nothing is registered. + /// + [Test] + public async Task RegisterWithoutCoinFailsAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var view = Mock.Get(player.ViewPlugIns.GetPlugIn()!); + + await this._actions.RegisterAsync(player).ConfigureAwait(false); + + view.Verify(v => v.ShowRegistrationResultAsync(LuckyCoinRegistrationResult.NotEnoughItems, 0), Times.Once); + } + + /// + /// Nothing happens when the player doesn't talk to Delgado. + /// + [Test] + public async Task RequiresOpenedDelgadoDialogAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var coins = await AddCoinsAsync(player, 3).ConfigureAwait(false); + player.OpenedNpc = null; + var view = Mock.Get(player.ViewPlugIns.GetPlugIn()!); + + await this._actions.RegisterAsync(player).ConfigureAwait(false); + await this._actions.ExchangeAsync(player, 10).ConfigureAwait(false); + + Assert.That(coins.Durability, Is.EqualTo(3)); + view.VerifyNoOtherCalls(); + } + + /// + /// An exchange takes the coins from several stacks and adds the reward to the inventory. + /// + [Test] + public async Task ExchangeGivesRewardAsync() + { + var player = await CreatePlayerAsync(new LuckyCoinReward { CoinCount = 10, ItemGroup = 14, ItemNumber = 13, ItemLevel = 0 }).ConfigureAwait(false); + player.GameContext.Configuration.Items.Add(new ItemDefinition { Group = 14, Number = 13, Width = 1, Height = 1, Durability = 1 }); + var firstStack = await AddCoinsAsync(player, 6).ConfigureAwait(false); + var secondStack = await AddCoinsAsync(player, 6).ConfigureAwait(false); + var view = Mock.Get(player.ViewPlugIns.GetPlugIn()!); + + await this._actions.ExchangeAsync(player, 10).ConfigureAwait(false); + + view.Verify(v => v.ShowExchangeResultAsync(LuckyCoinExchangeResult.Exchanged), Times.Once); + Assert.That(player.Inventory!.Items.Count(item => item.Definition is { Group: 14, Number: 13 }), Is.EqualTo(1)); + Assert.That(player.Inventory.Items.Where(item => item.Definition is { Group: 14, Number: 100 }).Sum(item => item.Durability), Is.EqualTo(2)); + Assert.That(new[] { firstStack, secondStack }.Count(stack => player.Inventory.Items.Contains(stack)), Is.EqualTo(1)); + } + + /// + /// An exchange fails without enough coins, without a reward for the count of coins, and without space for the reward. + /// The coins are kept then. + /// + [Test] + public async Task ExchangeRequirementsAsync() + { + var player = await CreatePlayerAsync(new LuckyCoinReward { CoinCount = 10, ItemGroup = 14, ItemNumber = 13, ItemLevel = 0 }).ConfigureAwait(false); + player.GameContext.Configuration.Items.Add(new ItemDefinition { Group = 14, Number = 13, Width = 10, Height = 10, Durability = 1 }); + var coins = await AddCoinsAsync(player, 9).ConfigureAwait(false); + var view = Mock.Get(player.ViewPlugIns.GetPlugIn()!); + + await this._actions.ExchangeAsync(player, 10).ConfigureAwait(false); + view.Verify(v => v.ShowExchangeResultAsync(LuckyCoinExchangeResult.NotEnoughItems), Times.Once); + + coins.Durability = 30; + await this._actions.ExchangeAsync(player, 20).ConfigureAwait(false); + view.Verify(v => v.ShowExchangeResultAsync(LuckyCoinExchangeResult.NotEnoughItems), Times.Exactly(2)); + + await this._actions.ExchangeAsync(player, 10).ConfigureAwait(false); + view.Verify(v => v.ShowExchangeResultAsync(LuckyCoinExchangeResult.InventoryFull), Times.Once); + Assert.That(coins.Durability, Is.EqualTo(30)); + } + + private static async ValueTask CreatePlayerAsync(params LuckyCoinReward[] rewards) + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + player.GameContext.FeaturePlugIns.AddPlugIn(new LuckyCoinFeaturePlugIn { Configuration = new LuckyCoinConfiguration { Rewards = rewards.ToList() } }, true); + player.OpenedNpc = new NonPlayerCharacter(null!, new MonsterDefinition { Number = 478, NpcWindow = NpcWindow.DelgadoLuckyCoinRegistration }, null!); + return player; + } + + private static async ValueTask AddCoinsAsync(Player player, int count) + { + var definition = player.GameContext.Configuration.Items.FirstOrDefault(item => item is { Group: 14, Number: 100 }); + if (definition is null) + { + definition = new ItemDefinition { Group = 14, Number = 100, Width = 1, Height = 1, Durability = 255 }; + player.GameContext.Configuration.Items.Add(definition); + } + + var coins = player.PersistenceContext.CreateNew(); + coins.Definition = definition; + coins.Durability = count; + Assert.That(await player.Inventory!.AddItemAsync(coins).ConfigureAwait(false), Is.True); + return coins; + } +}