From 54949af202525e471d585c288b8e3f58dd773d55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 17:56:27 +0000 Subject: [PATCH] feat(items): add the talisman of mobility Part of MUnique/OpenMU#1339 The first use saves the place of the character, the second one warps it back. The place is stored in stat attributes of the character. An optional update adds the item and puts the shop packages on sale. Rebased onto master: this is the change of the earlier commits in one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011xNc76CH3CGWUp5r4XmXWK --- .../docs/server-features/cash-shop.md | 13 ++ docs/CashShop.md | 5 +- src/GameLogic/Attributes/Stats.cs | 16 +++ src/GameLogic/ItemConstants.cs | 5 + .../TalismanOfMobilityConsumeHandlerPlugIn.cs | 110 +++++++++++++++ .../Properties/PlayerMessage.Designer.cs | 18 +++ .../Properties/PlayerMessage.de.resx | 6 + .../Properties/PlayerMessage.pt.resx | 6 + src/GameLogic/Properties/PlayerMessage.resx | 6 + .../Properties/PlugInResources.Designer.cs | 6 + .../Properties/PlugInResources.de.resx | 6 + src/GameLogic/Properties/PlugInResources.resx | 6 + .../Properties/PlugInResources.zh-CN.resx | 6 + .../Properties/ItemNames.Designer.cs | 7 + .../Properties/ItemNames.de.resx | 3 + .../Initialization/Properties/ItemNames.resx | 3 + .../Properties/ItemNames.zh-CN.resx | 3 + .../Properties/PlugInResources.Designer.cs | 6 + .../Properties/PlugInResources.de.resx | 6 + .../Properties/PlugInResources.resx | 2 + .../Properties/PlugInResources.zh-CN.resx | 6 + .../AddTalismanOfMobilityUpdatePlugIn.cs | 123 ++++++++++++++++ .../VersionSeasonSix/Items/CashShopItems.cs | 19 +++ .../VersionSeasonSix/Items/ItemRules.cs | 1 + .../CashShopUpdateTest.cs | 4 +- .../ItemStatProfilesDataTest.cs | 2 +- .../TalismanOfMobilityDataTest.cs | 132 ++++++++++++++++++ ...ismanOfMobilityConsumeHandlerPlugInTest.cs | 67 +++++++++ 28 files changed, 588 insertions(+), 5 deletions(-) create mode 100644 src/GameLogic/PlayerActions/ItemConsumeActions/TalismanOfMobilityConsumeHandlerPlugIn.cs create mode 100644 src/Persistence/Initialization/Updates/AddTalismanOfMobilityUpdatePlugIn.cs create mode 100644 tests/MUnique.OpenMU.Persistence.Initialization.Tests/TalismanOfMobilityDataTest.cs create mode 100644 tests/MUnique.OpenMU.Tests/TalismanOfMobilityConsumeHandlerPlugInTest.cs diff --git a/docs-website/docs/server-features/cash-shop.md b/docs-website/docs/server-features/cash-shop.md index 3539911e0a..2203e6b027 100644 --- a/docs-website/docs/server-features/cash-shop.md +++ b/docs-website/docs/server-features/cash-shop.md @@ -240,6 +240,19 @@ sell the card. While the plugin of a special item is deactivated, its packages can't be bought, and bought items stay in the storage until the plugin is active again. +### Talisman of Mobility + +The Talisman of Mobility can be used twice. The first use saves the place of +the character, the second one warps the character back to it and uses the +talisman up. The place is kept when the character leaves the game; saving +another place replaces it. The talisman can't be used in mini games and duels. +The plugin *Talisman of Mobility Consume Handler* can be deactivated on the +**Plugins** page. + +The item exists in a new Season 6 database. Existing databases get it with the +optional configuration update *Add the Talisman of Mobility*, which also puts +its packages on sale. + ### Invitation to Santa Village The Invitation to Santa Village can be used ten times. Each use warps the diff --git a/docs/CashShop.md b/docs/CashShop.md index d9e8799c08..f18a6a9a2b 100644 --- a/docs/CashShop.md +++ b/docs/CashShop.md @@ -287,11 +287,12 @@ catalog is initialized. They have no item rules (`ItemRules`), because they neve reach the inventory: a delivery plugin applies them, or they stay in the storage. Packages with a product that can't be delivered are initialized as not for sale. -That leaves 60 packages for sale: jewel bundles, Kundun boxes, Cherry Blossom +That leaves 62 packages for sale: jewel bundles, Kundun boxes, Cherry Blossom items and the Talisman of Luck 10% for Goblin Points, and the character cards, the Magic Backpack, the Vault Expansion Certificate, the free tickets of Devil Square, Blood Castle and Illusion Temple, the Kalima tickets, the reset fruits, -the elite potions and the Talisman of Luck for WCoin (C) and (P), and the ten +the elite potions, the Talisman of Luck and the Talisman of Mobility for +WCoin (C) and (P), and the ten lucky item tickets and the Gold Key and Silver Key for WCoin (P). Most cash shop items, like seals, scrolls and most talismans, aren't defined in the item configuration yet. diff --git a/src/GameLogic/Attributes/Stats.cs b/src/GameLogic/Attributes/Stats.cs index 51863ffb76..5a854bebb4 100644 --- a/src/GameLogic/Attributes/Stats.cs +++ b/src/GameLogic/Attributes/Stats.cs @@ -157,6 +157,22 @@ 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 map number of the place which a character saved with the Talisman of Mobility. + /// It's 1 more than the map number, so that 0 means that no place is saved. + /// + public static AttributeDefinition MobilityTalismanMap { get; } = new(new Guid("1F8D393B-EB1B-4777-A231-B97DAFB8072A"), "Talisman of Mobility Map", "The map number of the place which was saved with the Talisman of Mobility, plus 1. 0 means that no place is saved."); + + /// + /// Gets the attribute definition of the x coordinate of the place which a character saved with the Talisman of Mobility. + /// + public static AttributeDefinition MobilityTalismanX { get; } = new(new Guid("B0A203B6-1BD2-4AFD-8916-CCD2AAC47097"), "Talisman of Mobility X", "The x coordinate of the place which was saved with the Talisman of Mobility."); + + /// + /// Gets the attribute definition of the y coordinate of the place which a character saved with the Talisman of Mobility. + /// + public static AttributeDefinition MobilityTalismanY { get; } = new(new Guid("2CA2ED75-3BC4-47A1-A183-032A005D9CF6"), "Talisman of Mobility Y", "The y coordinate of the place which was saved with the Talisman of Mobility."); + /// /// Gets the zen amount rate attribute definition. /// diff --git a/src/GameLogic/ItemConstants.cs b/src/GameLogic/ItemConstants.cs index 3783b2339e..046dc2caa8 100644 --- a/src/GameLogic/ItemConstants.cs +++ b/src/GameLogic/ItemConstants.cs @@ -74,6 +74,11 @@ public class ItemConstants /// public static ItemIdentifier CommandResetFruit => new(58, 13); + /// + /// Gets the identifier for the Talisman of Mobility, which saves a place and warps the player back to it. + /// + public static ItemIdentifier TalismanOfMobility => new(70, 13); + /// /// Gets the identifier for the fenrir. /// diff --git a/src/GameLogic/PlayerActions/ItemConsumeActions/TalismanOfMobilityConsumeHandlerPlugIn.cs b/src/GameLogic/PlayerActions/ItemConsumeActions/TalismanOfMobilityConsumeHandlerPlugIn.cs new file mode 100644 index 0000000000..54971c74bd --- /dev/null +++ b/src/GameLogic/PlayerActions/ItemConsumeActions/TalismanOfMobilityConsumeHandlerPlugIn.cs @@ -0,0 +1,110 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.PlugIns; + +/// +/// Consume handler for the Talisman of Mobility. It has two usages: the first one saves the place of the character, +/// the second one warps the character back to the saved place. +/// +/// +/// The game client describes it as "Saves the location with the right-mouse-click. Returns to the saved location by a click.". +/// The place is saved at the character, so it's kept when the character leaves the game. +/// It can't be used in mini games and duels, because these have their own maps and spawn places. +/// +[Guid("23071045-4323-4B5F-B26B-5283EEB130C1")] +[PlugIn] +[Display(Name = nameof(PlugInResources.TalismanOfMobilityConsumeHandlerPlugIn_Name), Description = nameof(PlugInResources.TalismanOfMobilityConsumeHandlerPlugIn_Description), ResourceType = typeof(PlugInResources))] +public class TalismanOfMobilityConsumeHandlerPlugIn : BaseConsumeHandlerPlugIn +{ + /// + /// The durability of an unused talisman. With it, the place is saved; with less, the character returns to it. + /// + private const double UnusedDurability = 2; + + /// + public override ItemIdentifier Key => ItemConstants.TalismanOfMobility; + + /// + public override async ValueTask ConsumeItemAsync(Player player, Item item, Item? targetItem, FruitUsage fruitUsage) + { + if (!this.CheckPreconditions(player, item) + || player.SelectedCharacter is not { } character + || player.CurrentMap is not { } currentMap + || player.CurrentMiniGame is not null + || player.DuelRoom is not null) + { + return false; + } + + if (item.Durability >= UnusedDurability) + { + var position = player.Position; + SetValue(player, character, Stats.MobilityTalismanMap, currentMap.MapId + 1); + SetValue(player, character, Stats.MobilityTalismanX, position.X); + SetValue(player, character, Stats.MobilityTalismanY, position.Y); + await this.ConsumeSourceItemAsync(player, item).ConfigureAwait(false); + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.MobilityTalismanPlaceSaved)).ConfigureAwait(false); + return true; + } + + if (await GetSavedGateAsync(player, character).ConfigureAwait(false) is not { } gate) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.MobilityTalismanNoPlaceSaved)).ConfigureAwait(false); + return false; + } + + SetValue(player, character, Stats.MobilityTalismanMap, 0); + await this.ConsumeSourceItemAsync(player, item).ConfigureAwait(false); + await player.WarpToAsync(gate).ConfigureAwait(false); + return true; + } + + private static async ValueTask GetSavedGateAsync(Player player, Character character) + { + var mapNumber = (int)GetValue(character, Stats.MobilityTalismanMap) - 1; + if (mapNumber < 0 + || player.GameContext.Configuration.Maps.FirstOrDefault(map => map.Number == mapNumber) is not { IsActive: true } mapDefinition) + { + return null; + } + + var x = (byte)GetValue(character, Stats.MobilityTalismanX); + var y = (byte)GetValue(character, Stats.MobilityTalismanY); + + // The map may be hosted by another game server; the warp then moves the player to it. + var map = await player.GameContext.GetMapAsync((ushort)mapNumber).ConfigureAwait(false); + return new ExitGate + { + Map = map?.Definition ?? mapDefinition, + X1 = x, + X2 = x, + Y1 = y, + Y2 = y, + }; + } + + private static float GetValue(Character character, AttributeDefinition attribute) + { + return character.Attributes.FirstOrDefault(a => a.Definition?.Id == attribute.Id)?.Value ?? 0; + } + + private static void SetValue(Player player, Character character, AttributeDefinition attribute, float value) + { + if (character.Attributes.FirstOrDefault(a => a.Definition?.Id == attribute.Id) is { } existing) + { + existing.Value = value; + return; + } + + var definition = player.GameContext.Configuration.Attributes?.FirstOrDefault(a => a.Id == attribute.Id) ?? attribute; + character.Attributes.Add(player.PersistenceContext.CreateNew(definition, value)); + } +} diff --git a/src/GameLogic/Properties/PlayerMessage.Designer.cs b/src/GameLogic/Properties/PlayerMessage.Designer.cs index 451d767b67..af1a403b5c 100644 --- a/src/GameLogic/Properties/PlayerMessage.Designer.cs +++ b/src/GameLogic/Properties/PlayerMessage.Designer.cs @@ -3198,5 +3198,23 @@ public static string ItemBoxRequiresLevel { return ResourceManager.GetString("ItemBoxRequiresLevel", resourceCulture); } } + + /// + /// Looks up a localized string similar to The place has been saved. Use the talisman again to return to it.. + /// + public static string MobilityTalismanPlaceSaved { + get { + return ResourceManager.GetString("MobilityTalismanPlaceSaved", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No place has been saved with a Talisman of Mobility.. + /// + public static string MobilityTalismanNoPlaceSaved { + get { + return ResourceManager.GetString("MobilityTalismanNoPlaceSaved", resourceCulture); + } + } } } diff --git a/src/GameLogic/Properties/PlayerMessage.de.resx b/src/GameLogic/Properties/PlayerMessage.de.resx index c6b035c784..a3bc195cff 100644 --- a/src/GameLogic/Properties/PlayerMessage.de.resx +++ b/src/GameLogic/Properties/PlayerMessage.de.resx @@ -1021,4 +1021,10 @@ Inventarerweiterungen: {18} Dieser Warp ist nur für Game Master verfügbar. + + Der Ort wurde gespeichert. Benutze den Talisman erneut, um zu ihm zurückzukehren. + + + Mit einem Talisman der Mobilität wurde kein Ort gespeichert. + diff --git a/src/GameLogic/Properties/PlayerMessage.pt.resx b/src/GameLogic/Properties/PlayerMessage.pt.resx index 3f21ddfa66..7a21f8dfae 100644 --- a/src/GameLogic/Properties/PlayerMessage.pt.resx +++ b/src/GameLogic/Properties/PlayerMessage.pt.resx @@ -1174,4 +1174,10 @@ Extensões de inventário: {18} Este warp está disponível apenas para game masters. + + O local foi salvo. Use o talismã novamente para voltar a ele. + + + Nenhum local foi salvo com um Talismã da Mobilidade. + diff --git a/src/GameLogic/Properties/PlayerMessage.resx b/src/GameLogic/Properties/PlayerMessage.resx index 95b8eb4d9c..b955837cdb 100644 --- a/src/GameLogic/Properties/PlayerMessage.resx +++ b/src/GameLogic/Properties/PlayerMessage.resx @@ -1174,4 +1174,10 @@ Inventory Extensions: {18} This warp is only available to game masters. + + The place has been saved. Use the talisman again to return to it. + + + No place has been saved with a Talisman of Mobility. + diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index afe520fe7a..8e1c1cccf9 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -7710,5 +7710,11 @@ public static string WorldChatChatCommandPlugIn_Name { return ResourceManager.GetString("WorldChatChatCommandPlugIn_Name", resourceCulture); } } + + /// Gets the localized TalismanOfMobilityConsumeHandlerPlugIn_Name text. + public static string TalismanOfMobilityConsumeHandlerPlugIn_Name => ResourceManager.GetString("TalismanOfMobilityConsumeHandlerPlugIn_Name", resourceCulture)!; + + /// Gets the localized TalismanOfMobilityConsumeHandlerPlugIn_Description text. + public static string TalismanOfMobilityConsumeHandlerPlugIn_Description => ResourceManager.GetString("TalismanOfMobilityConsumeHandlerPlugIn_Description", resourceCulture)!; } } diff --git a/src/GameLogic/Properties/PlugInResources.de.resx b/src/GameLogic/Properties/PlugInResources.de.resx index c476911ee9..ccef7e1227 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. + + Verbrauch des Talismans der Mobilität + + + Plugin, das den Talisman der Mobilität verarbeitet. Die erste Benutzung speichert den Ort des Charakters, die zweite versetzt ihn an den gespeicherten Ort zurück. + diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index e212c3adc8..fc743b3d42 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. + + Talisman of Mobility Consume Handler + + + Plugin which handles the Talisman of Mobility. The first usage saves the place of the character, the second one warps it back to the saved place. + diff --git a/src/GameLogic/Properties/PlugInResources.zh-CN.resx b/src/GameLogic/Properties/PlugInResources.zh-CN.resx index c744611a71..92c3b363d5 100644 --- a/src/GameLogic/Properties/PlugInResources.zh-CN.resx +++ b/src/GameLogic/Properties/PlugInResources.zh-CN.resx @@ -1793,6 +1793,12 @@ 处理中瓶精华治疗药水的使用和消耗,立即恢复部分生命值。 + + 机动护身符使用 + + + 处理机动护身符。第一次使用保存角色的位置,第二次使用将角色传送回保存的位置。 + 开启幻影寺院活动命令 处理 /startit 命令,在下一个允许的时机开启幻影寺院活动。 登录超时 diff --git a/src/Persistence/Initialization/Properties/ItemNames.Designer.cs b/src/Persistence/Initialization/Properties/ItemNames.Designer.cs index a7cfd7fa65..46c486534f 100644 --- a/src/Persistence/Initialization/Properties/ItemNames.Designer.cs +++ b/src/Persistence/Initialization/Properties/ItemNames.Designer.cs @@ -5487,5 +5487,12 @@ public static string TalismanOfItemProtection { return ResourceManager.GetString("TalismanOfItemProtection", resourceCulture); } } + + /// Gets TalismanOfMobility from the configuration name resources. + public static string TalismanOfMobility { + get { + return ResourceManager.GetString("TalismanOfMobility", resourceCulture); + } + } } } diff --git a/src/Persistence/Initialization/Properties/ItemNames.de.resx b/src/Persistence/Initialization/Properties/ItemNames.de.resx index f73c5ea46f..4af14c77a2 100644 --- a/src/Persistence/Initialization/Properties/ItemNames.de.resx +++ b/src/Persistence/Initialization/Properties/ItemNames.de.resx @@ -2259,4 +2259,7 @@ Talisman des Gegenstandsschutzes + + Talisman der Mobilität + diff --git a/src/Persistence/Initialization/Properties/ItemNames.resx b/src/Persistence/Initialization/Properties/ItemNames.resx index 31ac86cfdf..b501c7a2a3 100644 --- a/src/Persistence/Initialization/Properties/ItemNames.resx +++ b/src/Persistence/Initialization/Properties/ItemNames.resx @@ -2343,4 +2343,7 @@ Talisman of Item Protection + + Talisman of Mobility + \ 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..2c6ebf08a5 100644 --- a/src/Persistence/Initialization/Properties/ItemNames.zh-CN.resx +++ b/src/Persistence/Initialization/Properties/ItemNames.zh-CN.resx @@ -2343,4 +2343,7 @@ 物品保护护身符 + + 机动护身符 + \ 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..ad290708b5 100644 --- a/src/Persistence/Initialization/Properties/PlugInResources.Designer.cs +++ b/src/Persistence/Initialization/Properties/PlugInResources.Designer.cs @@ -1079,6 +1079,12 @@ public static string DataInitialization095d_Name { /// Gets the localized AddSealItemDropRateUpdatePlugIn_Description text. public static string AddSealItemDropRateUpdatePlugIn_Description => ResourceManager.GetString("AddSealItemDropRateUpdatePlugIn_Description", resourceCulture)!; + /// Gets the localized AddTalismanOfMobilityUpdatePlugIn_Name text. + public static string AddTalismanOfMobilityUpdatePlugIn_Name => ResourceManager.GetString("AddTalismanOfMobilityUpdatePlugIn_Name", resourceCulture)!; + + /// Gets the localized AddTalismanOfMobilityUpdatePlugIn_Description text. + public static string AddTalismanOfMobilityUpdatePlugIn_Description => ResourceManager.GetString("AddTalismanOfMobilityUpdatePlugIn_Description", resourceCulture)!; + /// Gets the localized AdjustIceArrowFreezeDurationUpdatePlugIn_Name text. public static string AdjustIceArrowFreezeDurationUpdatePlugIn_Name => ResourceManager.GetString("AdjustIceArrowFreezeDurationUpdatePlugIn_Name", resourceCulture)!; diff --git a/src/Persistence/Initialization/Properties/PlugInResources.de.resx b/src/Persistence/Initialization/Properties/PlugInResources.de.resx index 7530b3ef21..969a3ca2aa 100644 --- a/src/Persistence/Initialization/Properties/PlugInResources.de.resx +++ b/src/Persistence/Initialization/Properties/PlugInResources.de.resx @@ -942,6 +942,12 @@ Dieses Update fügt dem Siegel des Wohlstands, dem Siegel der Göttlichkeit und dem Meistersiegel des Wohlstands die Item-Droprate von 150 % hinzu, und fügt das zweite Siegel des Wohlstands (13/116) hinzu, das nur die Item-Droprate erhöht. + + Talisman der Mobilität hinzufügen + + + Dieses Update fügt den Talisman der Mobilität hinzu, der einen Ort speichert und den Charakter dorthin zurückversetzt. Die Pakete des In-Game-Shops, die ihn enthalten, werden zum Verkauf angeboten. + Einfrierdauer des Eis-Pfeils anpassen diff --git a/src/Persistence/Initialization/Properties/PlugInResources.resx b/src/Persistence/Initialization/Properties/PlugInResources.resx index ca6308cb0c..7e3d42de48 100644 --- a/src/Persistence/Initialization/Properties/PlugInResources.resx +++ b/src/Persistence/Initialization/Properties/PlugInResources.resx @@ -446,6 +446,8 @@ Narrows the original Kanturu Ruins 3 warp landing area to walkable terrain. Customized destinations and unrelated settings are kept. Add the item drop rate of seals This update adds the item drop rate of 150 % to the Seals of Wealth and Divinity and the Master Seal of Wealth, and adds the second Seal of Wealth (13/116), which only raises the item drop rate. + Add the Talisman of Mobility + This update adds the Talisman of Mobility, which saves a place and warps the character back to it. The packages of the in-game shop which contain it are put on sale. Adjust Ice Arrow freeze duration Extends the default Ice Arrow freeze duration, and adds its reduction on successful hits. Customized durations are left unchanged. Also aligns Ice Arrow Str, Chain Drive Str, Strike of Destruction Str and Poison Str with their base skills magic effects. Season 6 monster spawns of Devias, Noria and Elvenland diff --git a/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx b/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx index 07180accdc..5f83073954 100644 --- a/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx +++ b/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx @@ -473,6 +473,12 @@ 此更新为财富之印、神圣之印和大师财富之印添加 150% 的物品掉落率,并添加只提高物品掉落率的第二个财富之印(13/116)。 + + 添加机动护身符 + + + 此更新添加机动护身符,可保存一个位置并将角色传送回该位置。包含它的游戏内商城礼包将开始出售。 + 添加受击硬直设置 添加全局受击硬直概率和伤害阈值设置,以及独角兽标记,使骑乘独角兽的玩家免受受击硬直效果。 修正坎特鲁废墟 3 的传送落点区域 diff --git a/src/Persistence/Initialization/Updates/AddTalismanOfMobilityUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/AddTalismanOfMobilityUpdatePlugIn.cs new file mode 100644 index 0000000000..3d9c75b4d4 --- /dev/null +++ b/src/Persistence/Initialization/Updates/AddTalismanOfMobilityUpdatePlugIn.cs @@ -0,0 +1,123 @@ +// +// 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.GameLogic.CashShop; +using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix; +using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Items; +using MUnique.OpenMU.PlugIns; + +/// +/// This update adds the Talisman of Mobility and puts the packages of the in-game shop which contain it on sale. +/// +[PlugIn] +[Display(Name = nameof(MUnique.OpenMU.Persistence.Initialization.Properties.PlugInResources.AddTalismanOfMobilityUpdatePlugIn_Name), Description = nameof(MUnique.OpenMU.Persistence.Initialization.Properties.PlugInResources.AddTalismanOfMobilityUpdatePlugIn_Description), ResourceType = typeof(MUnique.OpenMU.Persistence.Initialization.Properties.PlugInResources))] +[Guid("123277D4-E0F0-4F92-9F94-F64F15F840CD")] +public class AddTalismanOfMobilityUpdatePlugIn : UpdatePlugInBase +{ + /// + /// The plug in name. + /// + internal const string PlugInName = "Add the Talisman of Mobility"; + + /// + /// The plug in description. + /// + internal const string PlugInDescription = "This update adds the Talisman of Mobility, which saves a place and warps the character back to it. The packages of the in-game shop which contain it are put on sale."; + + /// + /// The product sequence of the products of the in-game shop which contain the Talisman of Mobility. + /// + private const int TalismanOfMobilityProductSequence = 41; + + /// + public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id; + + /// + public override string Name => PlugInName; + + /// + public override string Description => PlugInDescription; + + /// + /// + /// Like the update of the kalima ticket, it puts packages of the in-game shop on sale, + /// which a server owner may want to keep off sale, so it can be deselected. + /// + public override bool IsMandatory => false; + + /// + public override DateTime CreatedAt => new(2026, 11, 09, 12, 0, 0, DateTimeKind.Utc); + + /// + /// + /// The talisman gets the item rule flags, so these have to exist first. + /// + public override IEnumerable DependsOn => [typeof(AddItemRuleFlagsPlugIn).GUID]; + + /// + protected override ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration) + { + AddAttributes(context, gameConfiguration); + if (new CashShopItems(context, gameConfiguration).CreateTalismanOfMobility() is { } talisman) + { + ItemRules.Apply([talisman]); + } + + AssignCashShopProducts(gameConfiguration); + return ValueTask.CompletedTask; + } + + /// + /// Adds the attributes in which a character keeps the place which it saved with the talisman. + /// + /// The context. + /// The game configuration. + private static void AddAttributes(IContext context, GameConfiguration gameConfiguration) + { + foreach (var attribute in new[] { Stats.MobilityTalismanMap, Stats.MobilityTalismanX, Stats.MobilityTalismanY }) + { + if (gameConfiguration.Attributes.All(a => a.Id != attribute.Id)) + { + gameConfiguration.Attributes.Add(context.CreateNew(attribute.Id, attribute.Designation, attribute.Description)); + } + } + } + + /// + /// Assigns the talisman to the products of the in-game shop, which were created without it. + /// Their packages were initialized as not for sale then, so they're put on sale. + /// The product sequence only identifies these products in the script which comes with the game client, + /// so a configuration which uses a custom script stays untouched. + /// + private static void AssignCashShopProducts(GameConfiguration gameConfiguration) + { + if (gameConfiguration.CashShopConfiguration is not { } configuration + || !CashShopInitializer.UsesClientScript(configuration) + || gameConfiguration.Items.FirstOrDefault(item => item is { Group: 13, Number: 70 }) is not { } talisman) + { + return; + } + + foreach (var package in configuration.Packages) + { + var assigned = false; + foreach (var product in package.Products.Where(p => p.ItemDefinition is null && p.ProductSequence == TalismanOfMobilityProductSequence)) + { + product.ItemDefinition = talisman; + assigned = true; + } + + if (assigned && package.Products.All(product => product.CanBeDelivered())) + { + package.IsForSale = true; + } + } + } +} diff --git a/src/Persistence/Initialization/VersionSeasonSix/Items/CashShopItems.cs b/src/Persistence/Initialization/VersionSeasonSix/Items/CashShopItems.cs index ae1ec6802d..6e99ac1d4e 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Items/CashShopItems.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Items/CashShopItems.cs @@ -37,6 +37,25 @@ public override void Initialize() this.CreateItem(14, 163, LocalizedString.FromResource(() => ItemNames.VaultExpansionCertificate)); this.CreateItem(14, 169, LocalizedString.FromResource(() => ItemNames.RageFighterCharacterCard)); this.CreateResetFruits(); + this.CreateTalismanOfMobility(); + } + + /// + /// Creates the Talisman of Mobility, if it doesn't exist yet. Like in the client, it has a durability of 2: + /// the first usage saves the place of the character, the second one warps it back, + /// see . + /// The rules which block trading and selling it are applied by the . + /// + /// The created item, or null, if it already existed. + internal ItemDefinition? CreateTalismanOfMobility() + { + var talisman = this.CreateItem(13, 70, LocalizedString.FromResource(() => ItemNames.TalismanOfMobility)); + if (talisman is not null) + { + talisman.Durability = 2; + } + + return talisman; } /// diff --git a/src/Persistence/Initialization/VersionSeasonSix/Items/ItemRules.cs b/src/Persistence/Initialization/VersionSeasonSix/Items/ItemRules.cs index d5dab99c0c..372b7925fa 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Items/ItemRules.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Items/ItemRules.cs @@ -200,6 +200,7 @@ public enum Blocked Rule(13, 65, Blocked.Trade, Blocked.SellToNpc, Blocked.Repair), // Spirit of Guardian Rule(13, 67, Blocked.Repair), // Pet Rudolf Rule(13, 68, Blocked.Repair), // Snowman Transformation Ring + Rule(13, 70, Blocked.Trade, Blocked.Drop, Blocked.SellToNpc, Blocked.PersonalStore, Blocked.Repair), // Talisman of Mobility Rule(13, 76, Blocked.Trade, Blocked.SellToNpc, Blocked.Repair), // Panda Ring Rule(13, 80, Blocked.Trade, Blocked.SellToNpc, Blocked.Repair), // Pet Panda Rule(13, 81, Blocked.Trade, Blocked.Drop, Blocked.SellToNpc, Blocked.PersonalStore, Blocked.Repair), // Talisman of Guardian diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/CashShopUpdateTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/CashShopUpdateTest.cs index b03ce0237d..2aa060cb3f 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/CashShopUpdateTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/CashShopUpdateTest.cs @@ -124,7 +124,7 @@ public async Task FreshDatabaseContainsCashShopAsync() Assert.That( configuration.Packages.Where(p => p.IsForSale).SelectMany(p => p.Products), Has.All.Matches(product => product.ItemDefinition is not null && product.Duration == TimeSpan.Zero)); - Assert.That(configuration.Packages.Count(p => p.IsForSale), Is.EqualTo(60)); + Assert.That(configuration.Packages.Count(p => p.IsForSale), Is.EqualTo(62)); Assert.That(configuration.Packages.Where(p => DeliveryPackages.Contains(p.PackageSequence)), Has.All.Matches(p => p.IsForSale)); var seal = configuration.Packages.Single(p => p.PackageSequence == 122); @@ -180,7 +180,7 @@ public async Task UpdateAssignsDeliveryItemsToExistingCatalogAsync() await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); Assert.That(gameConfiguration.Items.Count(IsDeliveryItem), Is.EqualTo(4)); - Assert.That(configuration.Packages.Count(p => p.IsForSale), Is.EqualTo(60)); + Assert.That(configuration.Packages.Count(p => p.IsForSale), Is.EqualTo(62)); Assert.That( configuration.Packages.Where(p => DeliveryPackages.Contains(p.PackageSequence)).SelectMany(p => p.Products), Has.All.Matches(product => product.ItemDefinition is { } item && IsDeliveryItem(item))); diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemStatProfilesDataTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemStatProfilesDataTest.cs index 70bb95f006..a9f2842b1e 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: 244", "firstTierWing: 4", "helper: 27", "orb: 12", diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/TalismanOfMobilityDataTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/TalismanOfMobilityDataTest.cs new file mode 100644 index 0000000000..a9ea86c77b --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/TalismanOfMobilityDataTest.cs @@ -0,0 +1,132 @@ +// +// 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 Talisman of Mobility. +/// +[TestFixture] +internal class TalismanOfMobilityDataTest +{ + private static readonly short[] TalismanNumbers = [70]; + + /// + /// The packages of the in-game shop which contain the talisman, for WCoin (C) and (P). + /// + private static readonly int[] TalismanPackages = [68, 69]; + + /// + /// Tests that a new season 6 database contains the talisman, and that its packages are on sale. + /// + [Test] + public async Task NewDatabaseContainsTalismanAsync() + { + var (context, gameConfiguration) = await CreateSeason6ConfigurationAsync().ConfigureAwait(false); + using var _ = context; + + AssertTalismanData(gameConfiguration); + } + + /// + /// Tests that the update adds the talisman to a database which was created before it existed, + /// assigns it to the products of the in-game shop and puts its packages on sale. + /// Applying it twice doesn't add the talisman twice. + /// + [Test] + public async Task UpdateAddsTalismanToExistingDatabaseAsync() + { + var (context, gameConfiguration) = await CreateSeason6ConfigurationAsync().ConfigureAwait(false); + using var _ = context; + foreach (var package in gameConfiguration.CashShopConfiguration!.Packages.Where(p => TalismanPackages.Contains(p.PackageSequence))) + { + package.IsForSale = false; + package.Products.ToList().ForEach(product => product.ItemDefinition = null); + } + + gameConfiguration.Items.Where(IsTalisman).ToList().ForEach(item => gameConfiguration.Items.Remove(item)); + + var update = new AddTalismanOfMobilityUpdatePlugIn(); + await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + + AssertTalismanData(gameConfiguration); + } + + /// + /// Tests that the update doesn't assign the talisman to the products of a custom in-game shop script, + /// because its product sequences may identify other products, and doesn't put its packages on sale. + /// + [Test] + public async Task UpdateDoesNotAssignTalismanToCustomScriptAsync() + { + var (context, gameConfiguration) = await CreateSeason6ConfigurationAsync().ConfigureAwait(false); + using var _ = context; + var configuration = gameConfiguration.CashShopConfiguration!; + configuration.ScriptYearId++; + foreach (var package in configuration.Packages.Where(p => TalismanPackages.Contains(p.PackageSequence))) + { + package.IsForSale = false; + package.Products.ToList().ForEach(product => product.ItemDefinition = null); + } + + gameConfiguration.Items.Where(IsTalisman).ToList().ForEach(item => gameConfiguration.Items.Remove(item)); + + await new AddTalismanOfMobilityUpdatePlugIn().ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + + Assert.That(gameConfiguration.Items.Count(IsTalisman), Is.EqualTo(TalismanNumbers.Length)); + Assert.That(configuration.Packages.SelectMany(p => p.Products), Has.None.Matches(p => p.ItemDefinition is { } item && IsTalisman(item))); + Assert.That(configuration.Packages.Where(p => TalismanPackages.Contains(p.PackageSequence)), Has.None.Matches(p => p.IsForSale)); + } + + /// + /// Tests that the update adds the attributes in which a character keeps its saved place. + /// + [Test] + public async Task UpdateAddsAttributesAsync() + { + var (context, gameConfiguration) = await CreateSeason6ConfigurationAsync().ConfigureAwait(false); + using var _ = context; + var attributes = new[] { Stats.MobilityTalismanMap, Stats.MobilityTalismanX, Stats.MobilityTalismanY }; + gameConfiguration.Attributes.Where(attributes.Contains).ToList().ForEach(a => gameConfiguration.Attributes.Remove(a)); + + var update = new AddTalismanOfMobilityUpdatePlugIn(); + await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + + Assert.That(gameConfiguration.Attributes.Count(attributes.Contains), Is.EqualTo(3)); + } + + private static bool IsTalisman(ItemDefinition item) => item.Group == 13 && TalismanNumbers.Contains(item.Number); + + private static void AssertTalismanData(GameConfiguration gameConfiguration) + { + var talismans = gameConfiguration.Items.Where(IsTalisman).ToList(); + Assert.That(talismans.Select(item => item.Number), Is.EquivalentTo(TalismanNumbers)); + Assert.That(talismans, Has.All.Matches(item => item.Durability == 2 && !item.DropsFromMonsters && !item.IsTradable && !item.IsSellableToNpc && item.Name.ValueInNeutralLanguage.Length > 0)); + + var packages = gameConfiguration.CashShopConfiguration!.Packages.Where(p => TalismanPackages.Contains(p.PackageSequence)).ToList(); + Assert.That(packages, Has.Count.EqualTo(TalismanPackages.Length)); + Assert.That(packages, Has.All.Matches(p => p.IsForSale)); + Assert.That(packages.SelectMany(p => p.Products), Has.All.Matches(product => product.ItemDefinition is { } item && IsTalisman(item))); + } + + 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/TalismanOfMobilityConsumeHandlerPlugInTest.cs b/tests/MUnique.OpenMU.Tests/TalismanOfMobilityConsumeHandlerPlugInTest.cs new file mode 100644 index 0000000000..6bc9ab790f --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/TalismanOfMobilityConsumeHandlerPlugInTest.cs @@ -0,0 +1,67 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions; +using MUnique.OpenMU.Pathfinding; +using BasicModel = MUnique.OpenMU.Persistence.BasicModel; + +/// +/// Tests for the . +/// +[TestFixture] +public class TalismanOfMobilityConsumeHandlerPlugInTest +{ + private static readonly Point SavedPosition = new(100, 120); + + /// + /// The first usage saves the place and uses up one durability, the second one warps the character back to it. + /// + [Test] + public async Task SavesPlaceAndReturnsToItAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var talisman = CreateTalisman(); + var handler = new TalismanOfMobilityConsumeHandlerPlugIn(); + player.Position = SavedPosition; + + Assert.That(await handler.ConsumeItemAsync(player, talisman, null, FruitUsage.Undefined).ConfigureAwait(false), Is.True); + Assert.That(talisman.Durability, Is.EqualTo(1)); + + player.Position = new Point(30, 40); + Assert.That(await handler.ConsumeItemAsync(player, talisman, null, FruitUsage.Undefined).ConfigureAwait(false), Is.True); + Assert.That(talisman.Durability, Is.Zero); + Assert.That((player.SelectedCharacter!.PositionX, player.SelectedCharacter.PositionY), Is.EqualTo((SavedPosition.X, SavedPosition.Y))); + } + + /// + /// A used talisman without a saved place isn't used up. + /// + [Test] + public async Task UsedTalismanWithoutSavedPlaceIsKeptAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var talisman = CreateTalisman(); + talisman.Durability = 1; + + Assert.That(await new TalismanOfMobilityConsumeHandlerPlugIn().ConsumeItemAsync(player, talisman, null, FruitUsage.Undefined).ConfigureAwait(false), Is.False); + Assert.That(talisman.Durability, Is.EqualTo(1)); + } + + private static async ValueTask CreatePlayerAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + player.GameContext.Configuration.Maps.Single().IsActive = true; + return player; + } + + private static Item CreateTalisman() + { + var definition = new BasicModel.ItemDefinition { Group = 13, Number = 70, Width = 1, Height = 1, Durability = 2 }; + return new BasicModel.Item { Definition = definition, Durability = 2 }; + } +}