diff --git a/docs-website/docs/server-features/cash-shop.md b/docs-website/docs/server-features/cash-shop.md
index e8654081d5..012ed681fa 100644
--- a/docs-website/docs/server-features/cash-shop.md
+++ b/docs-website/docs/server-features/cash-shop.md
@@ -240,6 +240,18 @@ 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 Resurrection
+
+When a character dies while it carries a Talisman of Resurrection in its
+inventory, it's respawned at the place of its death instead of the safezone, and
+one talisman is used up. It doesn't apply in mini games, duels and guild wars,
+which have their own spawn places. The plugin *Talisman of Resurrection* 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 Resurrection*, 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..cd8d314b7a 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 Resurrection 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/ItemConstants.cs b/src/GameLogic/ItemConstants.cs
index 7263d9d269..0d16895ff7 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 Resurrection, which respawns a dead player at the place of its death.
+ ///
+ public static ItemIdentifier TalismanOfResurrection => new(69, 13);
+
///
/// Gets the identifier for the fenrir.
///
diff --git a/src/GameLogic/PlugIns/TalismanOfResurrectionSpawnGatePlugIn.cs b/src/GameLogic/PlugIns/TalismanOfResurrectionSpawnGatePlugIn.cs
new file mode 100644
index 0000000000..510127bf0c
--- /dev/null
+++ b/src/GameLogic/PlugIns/TalismanOfResurrectionSpawnGatePlugIn.cs
@@ -0,0 +1,83 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.PlugIns;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.GameLogic.CastleSiege;
+using MUnique.OpenMU.GameLogic.PlayerActions;
+using MUnique.OpenMU.GameLogic.Views.Inventory;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// A plugin which respawns a dead player at the place of its death, when it carries a Talisman of Resurrection
+/// in its inventory. One talisman is used up by it.
+///
+///
+/// It doesn't apply in mini games, duels, guild wars and to the participants of a running castle siege,
+/// which have their own spawn gates. It also doesn't apply when the player doesn't fulfill the requirements
+/// of the map anymore, e.g. when it lost its wings on Icarus, so that it's evacuated to the safezone.
+/// The item is configurable, by default it's the Talisman of Resurrection (13/69).
+///
+[PlugIn]
+[Display(Name = nameof(MUnique.OpenMU.GameLogic.Properties.PlugInResources.TalismanOfResurrectionSpawnGatePlugIn_Name), Description = nameof(MUnique.OpenMU.GameLogic.Properties.PlugInResources.TalismanOfResurrectionSpawnGatePlugIn_Description), ResourceType = typeof(MUnique.OpenMU.GameLogic.Properties.PlugInResources))]
+[Guid("5C057E6E-FECF-4216-AB78-1918AB00224E")]
+public class TalismanOfResurrectionSpawnGatePlugIn : IPlayerSpawnGateSelectionPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration
+{
+ ///
+ public TalismanOfResurrectionSpawnGatePlugInConfiguration? Configuration { get; set; }
+
+ ///
+ public async ValueTask SelectSpawnGateAsync(Player player, SpawnGateSelectionArgs args)
+ {
+ if (args.Gate is not null
+ || player.IsAlive
+ || player.CurrentMap is not { } map
+ || player.CurrentMiniGame is not null
+ || player.DuelRoom is not null
+ || player.GuildWarContext is not null
+ || IsCastleSiegeParticipant(player)
+ || map.Definition.TryGetRequirementError(player, out _)
+ || player.Inventory is not { } inventory
+ || inventory.Items.FirstOrDefault(this.IsTalismanOfResurrection) is not { } talisman)
+ {
+ return;
+ }
+
+ var position = player.Position;
+ args.Gate = new ExitGate
+ {
+ Map = map.Definition,
+ X1 = position.X,
+ X2 = position.X,
+ Y1 = position.Y,
+ Y2 = position.Y,
+ };
+
+ var slot = talisman.ItemSlot;
+ await inventory.RemoveItemAsync(talisman).ConfigureAwait(false);
+ await player.PersistenceContext.DeleteAsync(talisman).ConfigureAwait(false);
+ await player.InvokeViewPlugInAsync(p => p.RemoveItemAsync(slot)).ConfigureAwait(false);
+ }
+
+ ///
+ public object CreateDefaultConfig()
+ {
+ return new TalismanOfResurrectionSpawnGatePlugInConfiguration();
+ }
+
+ private static bool IsCastleSiegeParticipant(Player player)
+ {
+ return CastleSiegeContextResolver.GetContext(player) is { CurrentState: CastleSiegeState.Start } context
+ && context.GetPlayerJoinSide(player) != CastleSiegeJoinSide.None;
+ }
+
+ private bool IsTalismanOfResurrection(Item item)
+ {
+ var configuration = this.Configuration ??= new TalismanOfResurrectionSpawnGatePlugInConfiguration();
+ return item.Definition is { } definition
+ && definition.Group == configuration.ItemGroup
+ && definition.Number == configuration.ItemNumber;
+ }
+}
diff --git a/src/GameLogic/PlugIns/TalismanOfResurrectionSpawnGatePlugInConfiguration.cs b/src/GameLogic/PlugIns/TalismanOfResurrectionSpawnGatePlugInConfiguration.cs
new file mode 100644
index 0000000000..31fc0bdec8
--- /dev/null
+++ b/src/GameLogic/PlugIns/TalismanOfResurrectionSpawnGatePlugInConfiguration.cs
@@ -0,0 +1,23 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.PlugIns;
+
+///
+/// Configuration for the .
+///
+public class TalismanOfResurrectionSpawnGatePlugInConfiguration
+{
+ ///
+ /// Gets or sets the group of the item which respawns a dead player at the place of its death.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemGroup_Caption))]
+ public byte ItemGroup { get; set; } = ItemConstants.TalismanOfResurrection.Group;
+
+ ///
+ /// Gets or sets the number of the item which respawns a dead player at the place of its death.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemNumber_Caption))]
+ public short ItemNumber { get; set; } = ItemConstants.TalismanOfResurrection.Number!.Value;
+}
diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs
index 970ce07138..708b3252fd 100644
--- a/src/GameLogic/Properties/PlugInResources.Designer.cs
+++ b/src/GameLogic/Properties/PlugInResources.Designer.cs
@@ -7801,6 +7801,18 @@ public static string WorldChatChatCommandPlugIn_Name {
}
}
+ /// Gets the localized TalismanOfResurrectionSpawnGatePlugIn_Name text.
+ public static string TalismanOfResurrectionSpawnGatePlugIn_Name => ResourceManager.GetString("TalismanOfResurrectionSpawnGatePlugIn_Name", resourceCulture)!;
+
+ /// Gets the localized TalismanOfResurrectionSpawnGatePlugIn_Description text.
+ public static string TalismanOfResurrectionSpawnGatePlugIn_Description => ResourceManager.GetString("TalismanOfResurrectionSpawnGatePlugIn_Description", resourceCulture)!;
+
+ /// Gets the localized TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemGroup_Caption text.
+ public static string TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemGroup_Caption => ResourceManager.GetString("TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemGroup_Caption", resourceCulture)!;
+
+ /// Gets the localized TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemNumber_Caption text.
+ public static string TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemNumber_Caption => ResourceManager.GetString("TalismanOfResurrectionSpawnGatePlugInConfiguration_ItemNumber_Caption", resourceCulture)!;
+
///
/// Looks up a localized string similar to Lucky coin event.
///
diff --git a/src/GameLogic/Properties/PlugInResources.de.resx b/src/GameLogic/Properties/PlugInResources.de.resx
index 00c01b3582..8c2408195f 100644
--- a/src/GameLogic/Properties/PlugInResources.de.resx
+++ b/src/GameLogic/Properties/PlugInResources.de.resx
@@ -2016,6 +2016,18 @@
Teilt dem Client beim Betreten des Spiels mit, welche Warps der Spieler im Warp-Fenster auswählen kann.
+
+ Talisman der Wiederbelebung
+
+
+ Lässt einen toten Spieler am Ort seines Todes wieder erscheinen, wenn er einen Talisman der Wiederbelebung trägt, der dabei verbraucht wird. Gilt nicht in Minispielen, Duellen und Gildenkriegen.
+
+
+ Gegenstandsgruppe
+
+
+ Gegenstandsnummer
+
Glücksmünzen-Event
diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx
index 577a9aa606..59e45394ee 100644
--- a/src/GameLogic/Properties/PlugInResources.resx
+++ b/src/GameLogic/Properties/PlugInResources.resx
@@ -2061,6 +2061,14 @@
Tells the client which warps the player can select in the warp window, when the player enters the game.
+ Talisman of Resurrection
+ Respawns a dead player at the place of its death, when it carries a Talisman of Resurrection, which is used up. It doesn't apply in mini games, duels and guild wars.
+
+ Item group
+
+
+ Item number
+
Lucky coin event
diff --git a/src/GameLogic/Properties/PlugInResources.zh-CN.resx b/src/GameLogic/Properties/PlugInResources.zh-CN.resx
index a64888b890..ea891e8d50 100644
--- a/src/GameLogic/Properties/PlugInResources.zh-CN.resx
+++ b/src/GameLogic/Properties/PlugInResources.zh-CN.resx
@@ -1432,6 +1432,12 @@
将正在参加足球比赛的玩家生成在足球场上。
+
+ 复活护身符
+
+
+ 携带复活护身符的玩家死亡后在死亡地点复活,并消耗该护身符。不适用于小游戏、决斗和战盟战。
+
宠物经验分配
@@ -1804,6 +1810,12 @@
处理玩家与幻影寺院盟军物品仓库的交互。盟军将圣物送至此处可获得一分。
幻影寺院幻影物品仓库交互
处理玩家与幻影寺院幻影物品仓库的交互。幻影军将圣物送至此处可获得一分。
+
+ 物品组
+
+
+ 物品编号
+
幸运币活动
diff --git a/src/Persistence/Initialization/Properties/ItemNames.Designer.cs b/src/Persistence/Initialization/Properties/ItemNames.Designer.cs
index f845d52682..004560076a 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 TalismanOfResurrection from the configuration name resources.
+ public static string TalismanOfResurrection {
+ get {
+ return ResourceManager.GetString("TalismanOfResurrection", resourceCulture);
+ }
+ }
+
/// Gets LuckyCoin from the configuration name resources.
public static string LuckyCoin {
get {
diff --git a/src/Persistence/Initialization/Properties/ItemNames.de.resx b/src/Persistence/Initialization/Properties/ItemNames.de.resx
index 9ed4b96a03..f4c4147756 100644
--- a/src/Persistence/Initialization/Properties/ItemNames.de.resx
+++ b/src/Persistence/Initialization/Properties/ItemNames.de.resx
@@ -2259,6 +2259,9 @@
Talisman des Gegenstandsschutzes
+
+ Talisman der Wiederbelebung
+
Glücksmünze
diff --git a/src/Persistence/Initialization/Properties/ItemNames.resx b/src/Persistence/Initialization/Properties/ItemNames.resx
index c72a2f14b5..9eaee9c58f 100644
--- a/src/Persistence/Initialization/Properties/ItemNames.resx
+++ b/src/Persistence/Initialization/Properties/ItemNames.resx
@@ -2343,6 +2343,9 @@
Talisman of Item Protection
+
+ Talisman of Resurrection
+
Lucky Coin
diff --git a/src/Persistence/Initialization/Properties/ItemNames.zh-CN.resx b/src/Persistence/Initialization/Properties/ItemNames.zh-CN.resx
index 60b63e344a..8945813c42 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 7b578feb82..270a5092b0 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 AddTalismanOfResurrectionUpdatePlugIn_Name text.
+ public static string AddTalismanOfResurrectionUpdatePlugIn_Name => ResourceManager.GetString("AddTalismanOfResurrectionUpdatePlugIn_Name", resourceCulture)!;
+
+ /// Gets the localized AddTalismanOfResurrectionUpdatePlugIn_Description text.
+ public static string AddTalismanOfResurrectionUpdatePlugIn_Description => ResourceManager.GetString("AddTalismanOfResurrectionUpdatePlugIn_Description", resourceCulture)!;
+
/// Gets the localized AddLuckyCoinUpdatePlugIn_Name text.
public static string AddLuckyCoinUpdatePlugIn_Name => ResourceManager.GetString("AddLuckyCoinUpdatePlugIn_Name", resourceCulture)!;
diff --git a/src/Persistence/Initialization/Properties/PlugInResources.de.resx b/src/Persistence/Initialization/Properties/PlugInResources.de.resx
index d4ecda61a9..a41fbf4632 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.
+
+ Talisman der Wiederbelebung hinzufügen
+
+
+ Dieses Update fügt den Talisman der Wiederbelebung hinzu, der einen toten Spieler am Ort seines Todes wieder erscheinen lässt. Die Pakete des In-Game-Shops, die ihn enthalten, werden zum Verkauf angeboten.
+
Glücksmünze hinzufügen
diff --git a/src/Persistence/Initialization/Properties/PlugInResources.resx b/src/Persistence/Initialization/Properties/PlugInResources.resx
index 194b5e7e18..5438203e92 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 Talisman of Resurrection
+ This update adds the Talisman of Resurrection, which respawns a dead player at the place of its death. The packages of the in-game shop which contain it are put on sale.
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.
diff --git a/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx b/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx
index cc438fd497..9e3f1f650b 100644
--- a/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx
+++ b/src/Persistence/Initialization/Properties/PlugInResources.zh-CN.resx
@@ -489,6 +489,12 @@
修正罗兰峡谷通往狼魂要塞的入口
使罗兰峡谷中狼魂要塞传送门的南侧入口可以行走(攻城战以外的时间玩家使用该入口)。北侧入口及其他所有地块保持不变。
+
+ 添加复活护身符
+
+
+ 此更新添加复活护身符,使死亡的玩家在死亡地点复活。包含它的游戏内商城礼包将开始出售。
+
添加幸运币
diff --git a/src/Persistence/Initialization/Updates/AddTalismanOfResurrectionUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/AddTalismanOfResurrectionUpdatePlugIn.cs
new file mode 100644
index 0000000000..1254f2a8c1
--- /dev/null
+++ b/src/Persistence/Initialization/Updates/AddTalismanOfResurrectionUpdatePlugIn.cs
@@ -0,0 +1,94 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Persistence.Initialization.Updates;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.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 Resurrection and puts the packages of the in-game shop which contain it on sale.
+///
+[PlugIn]
+[Display(Name = nameof(PlugInResources.AddTalismanOfResurrectionUpdatePlugIn_Name), Description = nameof(PlugInResources.AddTalismanOfResurrectionUpdatePlugIn_Description), ResourceType = typeof(PlugInResources))]
+[Guid("363C62F6-E039-42C3-B5EC-1990C4C71A16")]
+public class AddTalismanOfResurrectionUpdatePlugIn : UpdatePlugInBase
+{
+ ///
+ /// The product sequence of the products of the in-game shop which contain the Talisman of Resurrection.
+ ///
+ private const int TalismanOfResurrectionProductSequence = 40;
+
+ ///
+ public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id;
+
+ ///
+ public override string Name => PlugInResources.AddTalismanOfResurrectionUpdatePlugIn_Name;
+
+ ///
+ public override string Description => PlugInResources.AddTalismanOfResurrectionUpdatePlugIn_Description;
+
+ ///
+ ///
+ /// 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, 10, 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)
+ {
+ if (new CashShopItems(context, gameConfiguration).CreateTalismanOfResurrection() is { } talisman)
+ {
+ ItemRules.Apply([talisman]);
+ }
+
+ AssignCashShopProducts(gameConfiguration);
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ /// 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: 69 }) 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 == TalismanOfResurrectionProductSequence))
+ {
+ 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..04f419e6c1 100644
--- a/src/Persistence/Initialization/VersionSeasonSix/Items/CashShopItems.cs
+++ b/src/Persistence/Initialization/VersionSeasonSix/Items/CashShopItems.cs
@@ -37,6 +37,19 @@ public override void Initialize()
this.CreateItem(14, 163, LocalizedString.FromResource(() => ItemNames.VaultExpansionCertificate));
this.CreateItem(14, 169, LocalizedString.FromResource(() => ItemNames.RageFighterCharacterCard));
this.CreateResetFruits();
+ this.CreateTalismanOfResurrection();
+ }
+
+ ///
+ /// Creates the Talisman of Resurrection, if it doesn't exist yet. When a player dies while it carries one
+ /// in its inventory, it's respawned at the place of its death and the talisman is used up,
+ /// see .
+ /// The rules which block trading and selling it are applied by the .
+ ///
+ /// The created item, or null, if it already existed.
+ internal ItemDefinition? CreateTalismanOfResurrection()
+ {
+ return this.CreateItem(13, 69, LocalizedString.FromResource(() => ItemNames.TalismanOfResurrection));
}
///
diff --git a/src/Persistence/Initialization/VersionSeasonSix/Items/ItemRules.cs b/src/Persistence/Initialization/VersionSeasonSix/Items/ItemRules.cs
index d1937f8967..869b10077f 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, 69, Blocked.Trade, Blocked.Drop, Blocked.SellToNpc, Blocked.PersonalStore, Blocked.Repair), // Talisman of Resurrection
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/TalismanOfResurrectionDataTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/TalismanOfResurrectionDataTest.cs
new file mode 100644
index 0000000000..201749d4d8
--- /dev/null
+++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/TalismanOfResurrectionDataTest.cs
@@ -0,0 +1,113 @@
+//
+// 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.Persistence.Initialization.Updates;
+using MUnique.OpenMU.Persistence.InMemory;
+
+///
+/// Tests the data of the Talisman of Resurrection.
+///
+[TestFixture]
+internal class TalismanOfResurrectionDataTest
+{
+ private static readonly short[] TalismanNumbers = [69];
+
+ ///
+ /// The packages of the in-game shop which contain the talisman, for WCoin (C) and (P).
+ ///
+ private static readonly int[] TalismanPackages = [70, 71];
+
+ ///
+ /// 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 AddTalismanOfResurrectionUpdatePlugIn();
+ 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 AddTalismanOfResurrectionUpdatePlugIn().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));
+ }
+
+ 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 == 1 && !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/TalismanOfResurrectionSpawnGatePlugInTest.cs b/tests/MUnique.OpenMU.Tests/TalismanOfResurrectionSpawnGatePlugInTest.cs
new file mode 100644
index 0000000000..8a66574b99
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/TalismanOfResurrectionSpawnGatePlugInTest.cs
@@ -0,0 +1,132 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using Moq;
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.PlugIns;
+using MUnique.OpenMU.Pathfinding;
+using BasicModel = MUnique.OpenMU.Persistence.BasicModel;
+
+///
+/// Tests for the .
+///
+[TestFixture]
+public class TalismanOfResurrectionSpawnGatePlugInTest
+{
+ private static readonly Point DeathPosition = new(100, 120);
+
+ ///
+ /// A dead player with a talisman is spawned at the place of its death, and the talisman is used up.
+ ///
+ [Test]
+ public async Task DeadPlayerWithTalismanSpawnsAtDeathPositionAsync()
+ {
+ var player = await CreateDeadPlayerAsync().ConfigureAwait(false);
+ var talisman = await AddItemAsync(player, 13, 69).ConfigureAwait(false);
+ var args = new SpawnGateSelectionArgs();
+
+ await new TalismanOfResurrectionSpawnGatePlugIn().SelectSpawnGateAsync(player, args).ConfigureAwait(false);
+
+ Assert.That(args.Gate, Is.Not.Null);
+ Assert.That(args.Gate!.Map, Is.SameAs(player.CurrentMap!.Definition));
+ Assert.That((args.Gate.X1, args.Gate.X2, args.Gate.Y1, args.Gate.Y2), Is.EqualTo((DeathPosition.X, DeathPosition.X, DeathPosition.Y, DeathPosition.Y)));
+ Assert.That(player.Inventory!.Items, Does.Not.Contain(talisman));
+ }
+
+ ///
+ /// A dead player without a talisman gets no gate from the plugin, so it's spawned at the safezone.
+ ///
+ [Test]
+ public async Task DeadPlayerWithoutTalismanGetsNoGateAsync()
+ {
+ var player = await CreateDeadPlayerAsync().ConfigureAwait(false);
+ var otherItem = await AddItemAsync(player, 13, 70).ConfigureAwait(false);
+ var args = new SpawnGateSelectionArgs();
+
+ await new TalismanOfResurrectionSpawnGatePlugIn().SelectSpawnGateAsync(player, args).ConfigureAwait(false);
+
+ Assert.That(args.Gate, Is.Null);
+ Assert.That(player.Inventory!.Items, Does.Contain(otherItem));
+ }
+
+ ///
+ /// A living player which is moved to the safezone keeps its talisman.
+ ///
+ [Test]
+ public async Task LivingPlayerKeepsTalismanAsync()
+ {
+ var player = await CreateDeadPlayerAsync().ConfigureAwait(false);
+ player.IsAlive = true;
+ var talisman = await AddItemAsync(player, 13, 69).ConfigureAwait(false);
+ var args = new SpawnGateSelectionArgs();
+
+ await new TalismanOfResurrectionSpawnGatePlugIn().SelectSpawnGateAsync(player, args).ConfigureAwait(false);
+
+ Assert.That(args.Gate, Is.Null);
+ Assert.That(player.Inventory!.Items, Does.Contain(talisman));
+ }
+
+ ///
+ /// A server can configure another item, which is then used instead of the Talisman of Resurrection.
+ ///
+ [Test]
+ public async Task ConfiguredItemIsUsedAsync()
+ {
+ var player = await CreateDeadPlayerAsync().ConfigureAwait(false);
+ var talisman = await AddItemAsync(player, 13, 69).ConfigureAwait(false);
+ var configuredItem = await AddItemAsync(player, 14, 200).ConfigureAwait(false);
+ var plugIn = new TalismanOfResurrectionSpawnGatePlugIn
+ {
+ Configuration = new TalismanOfResurrectionSpawnGatePlugInConfiguration { ItemGroup = 14, ItemNumber = 200 },
+ };
+ var args = new SpawnGateSelectionArgs();
+
+ await plugIn.SelectSpawnGateAsync(player, args).ConfigureAwait(false);
+
+ Assert.That(args.Gate, Is.Not.Null);
+ Assert.That(player.Inventory!.Items, Has.None.SameAs(configuredItem));
+ Assert.That(player.Inventory.Items, Has.Some.SameAs(talisman));
+ }
+
+ ///
+ /// A dead player which doesn't fulfill the requirements of the map anymore, e.g. because it lost its wings on Icarus,
+ /// is evacuated to the safezone and keeps its talisman.
+ ///
+ [Test]
+ public async Task PlayerWhichDoesNotFulfillMapRequirementsKeepsTalismanAsync()
+ {
+ var player = await CreateDeadPlayerAsync().ConfigureAwait(false);
+ var talisman = await AddItemAsync(player, 13, 69).ConfigureAwait(false);
+ Mock.Get(player.CurrentMap!.Definition)
+ .Setup(map => map.MapRequirements)
+ .Returns(new List { new BasicModel.AttributeRequirement { Attribute = Stats.CanFly, MinimumValue = 1 } });
+ var args = new SpawnGateSelectionArgs();
+
+ await new TalismanOfResurrectionSpawnGatePlugIn().SelectSpawnGateAsync(player, args).ConfigureAwait(false);
+
+ Assert.That(args.Gate, Is.Null);
+ Assert.That(player.Inventory!.Items, Does.Contain(talisman));
+ }
+
+ private static async ValueTask CreateDeadPlayerAsync()
+ {
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ player.Position = DeathPosition;
+ player.IsAlive = false;
+ return player;
+ }
+
+ private static async ValueTask- AddItemAsync(Player player, byte group, short number)
+ {
+ var definition = new BasicModel.ItemDefinition { Group = group, Number = number, Width = 1, Height = 1, Durability = 1 };
+ var item = new BasicModel.Item { Definition = definition, Durability = 1 };
+ Assert.That(await player.Inventory!.AddItemAsync(item).ConfigureAwait(false), Is.True);
+ return item;
+ }
+}