diff --git a/docs/Packets/C1-92-UpdateMiniGameState_by-server.md b/docs/Packets/C1-92-UpdateMiniGameState_by-server.md
index 383a31e77b..375b2b2b4e 100644
--- a/docs/Packets/C1-92-UpdateMiniGameState_by-server.md
+++ b/docs/Packets/C1-92-UpdateMiniGameState_by-server.md
@@ -34,4 +34,8 @@ The state of the mini games.
| 10 | ChaosCastleClosed | The chaos castle game is closed. "Chaos Castle Closing (in x seconds)". |
| 11 | ChaosCastleOpened | The chaos castle game is opened for entrance. "Chaos Castle Penetration (in x seconds)". |
| 12 | ChaosCastleEnding | The chaos castle game is ending. "Chaos Castle Event ends (in x seconds)". |
-| 13 | ChaosCastleFinished | The chaos castle game is finished. Chaos Castle Event shuts down (in x seconds)". |
\ No newline at end of file
+| 13 | ChaosCastleFinished | The chaos castle game is finished. Chaos Castle Event shuts down (in x seconds)". |
+| 16 | DoppelgangerEntranceClosing | The entrance of the doppelganger event closes in 30 seconds. The client shows the countdown only on the doppelganger maps. |
+| 17 | DoppelgangerStarting | The doppelganger event starts in 30 seconds. The client shows the countdown only on the doppelganger maps. |
+| 18 | DoppelgangerIceWalkerCountdown | The ice walker has to be killed within 30 seconds. The client shows the countdown only on the doppelganger maps. |
+| 19 | DoppelgangerEnding | The doppelganger event ends in 30 seconds. The client shows the countdown only on the doppelganger maps. |
\ No newline at end of file
diff --git a/docs/Packets/C1-BF-0E-DoppelgangerEnterResult_by-server.md b/docs/Packets/C1-BF-0E-DoppelgangerEnterResult_by-server.md
new file mode 100644
index 0000000000..bc7a48209b
--- /dev/null
+++ b/docs/Packets/C1-BF-0E-DoppelgangerEnterResult_by-server.md
@@ -0,0 +1,31 @@
+# C1 BF 0E - DoppelgangerEnterResult (by server)
+
+## Is sent when
+
+The player requested to enter the doppelganger event through the NPC Lugard.
+
+## Causes the following actions on the client side
+
+On failure, the client locks the enter button of the doppelganger entry dialog and may show a message.
+
+## 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 | 0x0E | Packet header - sub packet type identifier |
+| 4 | 1 | EnterResult | | Result |
+
+### EnterResult Enum
+
+Result of the doppelganger enter request.
+
+| Value | Name | Description |
+|-------|------|-------------|
+| 0 | Success | The player entered the event. |
+| 1 | Failed | The player could not enter, e.g. because of a missing ticket item. The client locks the enter button without showing a message. |
+| 2 | AlreadyStarted | The event is already running or occupied by another party. The client shows "Battle has already commenced. You cannot enter.". |
+| 3 | PlayerKiller | Player killers are not allowed to enter. The client shows "You cannot enter if you are a 1st Stage Outlaw.". |
+| 4 | EntranceAvailable | The client unlocks the enter button of the doppelganger entry dialog. |
\ No newline at end of file
diff --git a/docs/Packets/C1-BF-0F-DoppelgangerMonsterPosition_by-server.md b/docs/Packets/C1-BF-0F-DoppelgangerMonsterPosition_by-server.md
new file mode 100644
index 0000000000..9a946b2a4e
--- /dev/null
+++ b/docs/Packets/C1-BF-0F-DoppelgangerMonsterPosition_by-server.md
@@ -0,0 +1,19 @@
+# C1 BF 0F - DoppelgangerMonsterPosition (by server)
+
+## Is sent when
+
+The position of the most advanced monster on the path to the magic circle changed during the doppelganger event.
+
+## Causes the following actions on the client side
+
+The client updates the monster progress bar of the doppelganger frame.
+
+## 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 | 0x0F | Packet header - sub packet type identifier |
+| 4 | 1 | Byte | | Position; The position index on the path, between 0 (start) and 22 (magic circle). |
\ No newline at end of file
diff --git a/docs/Packets/C1-BF-10-DoppelgangerStateUpdate_by-server.md b/docs/Packets/C1-BF-10-DoppelgangerStateUpdate_by-server.md
new file mode 100644
index 0000000000..eae74f682e
--- /dev/null
+++ b/docs/Packets/C1-BF-10-DoppelgangerStateUpdate_by-server.md
@@ -0,0 +1,30 @@
+# C1 BF 10 - DoppelgangerStateUpdate (by server)
+
+## Is sent when
+
+The state of the doppelganger event changed.
+
+## Causes the following actions on the client side
+
+When the event starts (state Playing), the client shows the doppelganger frame and a message box with the failure conditions.
+
+## 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 | 0x10 | Packet header - sub packet type identifier |
+| 4 | 1 | DoppelgangerState | | State |
+
+### DoppelgangerState Enum
+
+The state of the doppelganger event.
+
+| Value | Name | Description |
+|-------|------|-------------|
+| 0 | Waiting | The event is waiting for the party members to enter. |
+| 1 | Ready | The entrance is closed and the event is about to start. |
+| 2 | Playing | The event is running. |
+| 3 | Ended | The event has ended. |
\ No newline at end of file
diff --git a/docs/Packets/C1-BF-11-DoppelgangerIceWalkerState_by-server.md b/docs/Packets/C1-BF-11-DoppelgangerIceWalkerState_by-server.md
new file mode 100644
index 0000000000..c874edd71c
--- /dev/null
+++ b/docs/Packets/C1-BF-11-DoppelgangerIceWalkerState_by-server.md
@@ -0,0 +1,29 @@
+# C1 BF 11 - DoppelgangerIceWalkerState (by server)
+
+## Is sent when
+
+The ice walker appeared on or disappeared from the path during the doppelganger event.
+
+## Causes the following actions on the client side
+
+The client shows or hides the ice walker icon on the progress bar of the doppelganger frame.
+
+## Structure
+
+| Index | Length | Data Type | Value | Description |
+|-------|--------|-----------|-------|-------------|
+| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
+| 1 | 1 | Byte | 6 | Packet header - length of the packet |
+| 2 | 1 | Byte | 0xBF | Packet header - packet type identifier |
+| 3 | 1 | Byte | 0x11 | Packet header - sub packet type identifier |
+| 4 | 1 | IceWalkerState | | State |
+| 5 | 1 | Byte | | Position; The position index of the ice walker on the path, between 0 (start) and 22 (magic circle). |
+
+### IceWalkerState Enum
+
+The state of the ice walker.
+
+| Value | Name | Description |
+|-------|------|-------------|
+| 0 | Appeared | The ice walker is present at the given position. |
+| 1 | Disappeared | The ice walker was killed or disappeared. |
\ No newline at end of file
diff --git a/docs/Packets/C1-BF-12-DoppelgangerPlayInfo_by-server.md b/docs/Packets/C1-BF-12-DoppelgangerPlayInfo_by-server.md
new file mode 100644
index 0000000000..0a94507d06
--- /dev/null
+++ b/docs/Packets/C1-BF-12-DoppelgangerPlayInfo_by-server.md
@@ -0,0 +1,33 @@
+# C1 BF 12 - DoppelgangerPlayInfo (by server)
+
+## Is sent when
+
+Periodically (every second) while the doppelganger event is running.
+
+## Causes the following actions on the client side
+
+The client updates the remaining time and the positions of the party members on the progress bar of the doppelganger frame. Players which are not included are no longer shown.
+
+## Structure
+
+| Index | Length | Data Type | Value | Description |
+|-------|--------|-----------|-------|-------------|
+| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
+| 1 | 1 | Byte | | Packet header - length of the packet |
+| 2 | 1 | Byte | 0xBF | Packet header - packet type identifier |
+| 3 | 1 | Byte | 0x12 | Packet header - sub packet type identifier |
+| 4 | 2 | ShortLittleEndian | | RemainingSeconds |
+| 6 | 1 | Byte | | PlayerCount |
+| 8 | PlayerPosition.Length * PlayerCount | Array of PlayerPosition | | PlayerPositions |
+
+### PlayerPosition Structure
+
+The position of a player on the path.
+
+Length: 4 Bytes
+
+| Index | Length | Data Type | Value | Description |
+|-------|--------|-----------|-------|-------------|
+| 0 | 2 | ShortLittleEndian | | PlayerId |
+| 2 | 1 | Byte | | MapNumber; The map number of the player. It is ignored by the client. |
+| 3 | 1 | Byte | | Position; The position index of the player on the path, between 0 (start) and 22 (magic circle). |
\ No newline at end of file
diff --git a/docs/Packets/C1-BF-13-DoppelgangerResult_by-server.md b/docs/Packets/C1-BF-13-DoppelgangerResult_by-server.md
new file mode 100644
index 0000000000..4a0f77ac01
--- /dev/null
+++ b/docs/Packets/C1-BF-13-DoppelgangerResult_by-server.md
@@ -0,0 +1,30 @@
+# C1 BF 13 - DoppelgangerResult (by server)
+
+## Is sent when
+
+The doppelganger event ended for the player.
+
+## Causes the following actions on the client side
+
+The client stops the timer and the event music, and shows a message box with the result.
+
+## 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 | 0x13 | Packet header - sub packet type identifier |
+| 4 | 1 | ResultType | | Result |
+| 8 | 4 | IntegerLittleEndian | | RewardExperience; The experience which the player got as reward. It is not shown by the client. The field is aligned to 4 bytes, because the client structure is not packed. |
+
+### ResultType Enum
+
+The result of the doppelganger event.
+
+| Value | Name | Description |
+|-------|------|-------------|
+| 0 | Success | The party successfully defended the magic circle. |
+| 1 | Failed | The player failed, e.g. because the character died or left the event map. |
+| 2 | MonstersReachedMagicCircle | The defense failed, because too many monsters reached the magic circle. |
\ No newline at end of file
diff --git a/docs/Packets/C1-BF-14-DoppelgangerMonsterGoal_by-server.md b/docs/Packets/C1-BF-14-DoppelgangerMonsterGoal_by-server.md
new file mode 100644
index 0000000000..8d70f5260d
--- /dev/null
+++ b/docs/Packets/C1-BF-14-DoppelgangerMonsterGoal_by-server.md
@@ -0,0 +1,20 @@
+# C1 BF 14 - DoppelgangerMonsterGoal (by server)
+
+## Is sent when
+
+A monster reached the magic circle during the doppelganger event.
+
+## Causes the following actions on the client side
+
+The client updates the counter of monsters which passed the magic circle.
+
+## Structure
+
+| Index | Length | Data Type | Value | Description |
+|-------|--------|-----------|-------|-------------|
+| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
+| 1 | 1 | Byte | 6 | Packet header - length of the packet |
+| 2 | 1 | Byte | 0xBF | Packet header - packet type identifier |
+| 3 | 1 | Byte | 0x14 | Packet header - sub packet type identifier |
+| 4 | 1 | Byte | | MaximumGoalCount; The number of monsters which may reach the magic circle until the event fails. |
+| 5 | 1 | Byte | | GoalCount; The number of monsters which reached the magic circle. |
\ No newline at end of file
diff --git a/docs/Packets/ServerToClient.md b/docs/Packets/ServerToClient.md
index 7fbc988d04..73d5b31f96 100644
--- a/docs/Packets/ServerToClient.md
+++ b/docs/Packets/ServerToClient.md
@@ -198,6 +198,13 @@
* [C1 BF 07 - IllusionTempleSkillEnd (by server)](C1-BF-07-IllusionTempleSkillEnd_by-server.md)
* [C1 BF 08 - IllusionTempleHolyItemRelics (by server)](C1-BF-08-IllusionTempleHolyItemRelics_by-server.md)
* [C1 BF 0A - ChainLightningHitInfo (by server)](C1-BF-0A-ChainLightningHitInfo_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)
+ * [C1 BF 10 - DoppelgangerStateUpdate (by server)](C1-BF-10-DoppelgangerStateUpdate_by-server.md)
+ * [C1 BF 11 - DoppelgangerIceWalkerState (by server)](C1-BF-11-DoppelgangerIceWalkerState_by-server.md)
+ * [C1 BF 12 - DoppelgangerPlayInfo (by server)](C1-BF-12-DoppelgangerPlayInfo_by-server.md)
+ * [C1 BF 13 - DoppelgangerResult (by server)](C1-BF-13-DoppelgangerResult_by-server.md)
+ * [C1 BF 14 - DoppelgangerMonsterGoal (by server)](C1-BF-14-DoppelgangerMonsterGoal_by-server.md)
* [C1 BF 51 - MuHelperStatusUpdate (by server)](C1-BF-51-MuHelperStatusUpdate_by-server.md)
* [C2 C0 - MessengerInitialization (by server)](C2-C0-MessengerInitialization_by-server.md)
* [C1 C1 01 - FriendAdded (by server)](C1-C1-01-FriendAdded_by-server.md)
diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs
new file mode 100644
index 0000000000..e14ffd1a57
--- /dev/null
+++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs
@@ -0,0 +1,809 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+
+using System.Collections.Concurrent;
+using System.Threading;
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.NPC;
+using MUnique.OpenMU.GameLogic.Views;
+using MUnique.OpenMU.GameLogic.Views.World;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// The context of a doppelganger event game.
+///
+///
+/// The players defend a magic circle against monsters which walk along a path towards it.
+/// Herds of monsters spawn at the start of the path in a fixed interval, additional stronger
+/// monsters at specific times. At some point, ice walkers appear on the path, which have to
+/// be killed within a limited time.
+/// The monsters get stronger with the level and the number of the players. When the ice walkers
+/// weren't killed in time, the following monsters get even stronger.
+/// Killed butchers leave interim reward chests behind, of which only one can be opened by talking to it.
+/// It contains either items or larvae. When the defense succeeded, a final reward chest appears.
+/// The event fails for everyone when
+/// monsters reached the magic circle. It fails for a single player when the character
+/// dies or leaves the event map, e.g. by warping or disconnecting.
+/// When the game time is over and the defense didn't fail, the remaining players succeeded.
+///
+public sealed class DoppelgangerContext : MiniGameContext
+{
+ ///
+ /// The health of the chests. They're opened by talking to them, so they shouldn't be destroyed by attacks.
+ ///
+ private const int ChestHealth = 1_000_000_000;
+
+ ///
+ /// The maximum distance of a player to a chest to open it.
+ ///
+ private const int MaximumChestOpenDistance = 4;
+
+ ///
+ /// The duration of the countdown which the client shows for the ice walker mission.
+ ///
+ private static readonly TimeSpan IceWalkerCountdownDuration = TimeSpan.FromSeconds(30);
+
+ ///
+ /// The time after which an opened chest is removed, so that the client can show its opening.
+ ///
+ private static readonly TimeSpan OpenedChestRemovalDelay = TimeSpan.FromSeconds(2);
+
+ private readonly IGameContext _gameContext;
+ private readonly DoppelgangerEventDefinition _definition;
+ private readonly IList _path;
+
+ ///
+ /// The players for which the event already ended with a result, e.g. because they died.
+ ///
+ private readonly ConcurrentDictionary _playerResults = new();
+
+ ///
+ /// The monsters on the path, including the ice walkers.
+ ///
+ private readonly ConcurrentDictionary _pathMonsters = new();
+
+ private readonly ConcurrentDictionary _iceWalkers = new();
+
+ ///
+ /// The interim reward chests, with the group of chests which appeared together.
+ ///
+ private readonly ConcurrentDictionary _interimChests = new();
+
+ private DateTime _gameStartedUtc;
+ private DateTime _gameEndsAtUtc;
+ private int _goalCount;
+ private int _lastShownMonsterPosition = -1;
+ private int _initialPlayerCount;
+ private MonsterMultipliers _monsterMultipliers = MonsterMultipliers.None;
+ private int _isIceWalkerMissionFailed;
+ private Destructible? _finalChest;
+ private int _isFinalChestOpened;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The key of this context.
+ /// The definition of the mini game.
+ /// The game context, to which this game belongs.
+ /// The map initializer, which is used when the event starts.
+ public DoppelgangerContext(
+ MiniGameMapKey key,
+ MiniGameDefinition definition,
+ IGameContext gameContext,
+ IMapInitializer mapInitializer)
+ : base(key, definition, gameContext, mapInitializer)
+ {
+ this._gameContext = gameContext;
+
+ // The definition is resolved once, so that a configuration change doesn't affect a running game.
+ this._definition = GetEventDefinition(gameContext);
+ var mapNumber = (short)this.Map.MapId;
+ this._path = this._definition.Paths.FirstOrDefault(path => path.MapNumber == mapNumber)?.Areas ?? [];
+ }
+
+ ///
+ /// Gets a value indicating whether the defense failed, because too many monsters reached the magic circle.
+ ///
+ public bool IsDefenseFailed => Volatile.Read(ref this._goalCount) >= this._definition.MaximumGoalCount;
+
+ ///
+ protected override TimeSpan RemainingTime
+ {
+ get
+ {
+ var remaining = this._gameEndsAtUtc - DateTime.UtcNow;
+ return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
+ }
+ }
+
+ ///
+ /// Registers a monster which reached the magic circle. When too many monsters
+ /// reached it, the defense fails and the event ends.
+ ///
+ public async ValueTask RegisterMonsterReachedMagicCircleAsync()
+ {
+ if (this.State != MiniGameState.Playing)
+ {
+ return;
+ }
+
+ var goalCount = Interlocked.Increment(ref this._goalCount);
+
+ // When several monsters reach the magic circle at the same time, the count can exceed the maximum.
+ var shownGoalCount = Math.Min(goalCount, this._definition.MaximumGoalCount);
+ await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p =>
+ p.ShowMonsterGoalAsync(shownGoalCount, this._definition.MaximumGoalCount)).AsTask()).ConfigureAwait(false);
+
+ if (goalCount == this._definition.MaximumGoalCount)
+ {
+ this.Logger.LogInformation("{context}: The defense failed, because {goalCount} monsters reached the magic circle.", this, goalCount);
+ this.FinishEvent();
+ }
+ }
+
+ ///
+ /// Determines whether the destructible is a reward chest of this game, which wasn't opened yet.
+ ///
+ /// The chest.
+ /// true, if the destructible is a reward chest of this game; otherwise, false.
+ public bool IsRewardChest(Destructible chest)
+ {
+ return this._interimChests.ContainsKey(chest) || chest == this._finalChest;
+ }
+
+ ///
+ /// Opens the reward chest. Of the interim chests which appeared together, only one can be opened.
+ /// An interim chest contains either larvae or items, the final chest always contains items.
+ ///
+ /// The player who opens the chest.
+ /// The chest.
+ public async ValueTask OpenRewardChestAsync(Player player, Destructible chest)
+ {
+ if (player.CurrentMiniGame != this
+ || !chest.IsAlive
+ || chest.GetDistanceTo(player) > MaximumChestOpenDistance)
+ {
+ return;
+ }
+
+ var containsLarvae = false;
+ if (this._interimChests.TryRemove(chest, out var group))
+ {
+ if (!group.TryOpen())
+ {
+ return;
+ }
+
+ containsLarvae = Rand.NextRandomBool(this._definition.LarvaChance);
+ foreach (var otherChest in group.Chests.Where(c => c != chest))
+ {
+ if (this._interimChests.TryRemove(otherChest, out _))
+ {
+ await this.RemoveNpcAsync(otherChest).ConfigureAwait(false);
+ }
+ }
+ }
+ else if (chest != this._finalChest || Interlocked.Exchange(ref this._isFinalChestOpened, 1) != 0)
+ {
+ return;
+ }
+ else
+ {
+ // It's the final chest, which always contains items.
+ }
+
+ this.Logger.LogDebug("{context}: {player} opened {chest}, which contains {content}.", this, player, chest, containsLarvae ? "larvae" : "items");
+
+ // The client shows the opening of the chest as its death.
+ await chest.ForEachWorldObserverAsync(p => p.ObjectGotKilledAsync(chest, player), true).ConfigureAwait(false);
+ if (containsLarvae)
+ {
+ var area = GetAreaAround(chest.Position, 2);
+ for (var i = 0; i < Math.Max(1, this._initialPlayerCount); i++)
+ {
+ await this.SpawnMonsterAsync(this._definition.Larva, area, 0, false, true).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ await this.DropChestItemsAsync(player, chest).ConfigureAwait(false);
+ }
+
+ // The talk to the chest isn't awaited, so waiting here for the opening animation doesn't block anything.
+ await Task.Delay(OpenedChestRemovalDelay).ConfigureAwait(false);
+ await this.RemoveNpcAsync(chest).ConfigureAwait(false);
+ }
+
+ ///
+ protected override async ValueTask OnGameStartAsync(ICollection players)
+ {
+ await base.OnGameStartAsync(players).ConfigureAwait(false);
+
+ this._gameStartedUtc = DateTime.UtcNow;
+ this._initialPlayerCount = players.Count;
+ this._monsterMultipliers = this.GetMonsterMultipliers(players);
+ this._gameEndsAtUtc = this._gameStartedUtc.Add(this.Definition.GameDuration);
+ await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(async p =>
+ {
+ await p.ShowStateAsync(DoppelgangerState.Playing).ConfigureAwait(false);
+ await p.ShowMonsterGoalAsync(0, this._definition.MaximumGoalCount).ConfigureAwait(false);
+ }).AsTask()).ConfigureAwait(false);
+
+ _ = Task.Run(() => this.RunPlayInfoLoopAsync(this.GameEndedToken), this.GameEndedToken);
+ _ = Task.Run(() => this.RunMonsterSpawnsAsync(this.GameEndedToken), this.GameEndedToken);
+ }
+
+ ///
+#pragma warning disable VSTHRD100 // Avoid async void methods
+ protected override async void OnMonsterDied(object? sender, DeathInformation e)
+#pragma warning restore VSTHRD100
+ {
+ try
+ {
+ base.OnMonsterDied(sender, e);
+
+ if (sender is not Monster monster)
+ {
+ return;
+ }
+
+ this._pathMonsters.TryRemove(monster, out _);
+ if (this.State == MiniGameState.Playing && this._definition.InterimChestMonsters.Any(m => IsSameMonster(m, monster.Definition)))
+ {
+ await this.SpawnInterimChestsAsync(monster.Position).ConfigureAwait(false);
+ }
+
+ if (this._iceWalkers.TryRemove(monster, out _) && this._iceWalkers.IsEmpty)
+ {
+ this.Logger.LogDebug("{context}: All ice walkers were killed.", this);
+ await this.ShowGoldenMessageAsync(nameof(PlayerMessage.DoppelgangerIceWalkerKilled)).ConfigureAwait(false);
+ await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.HideIceWalkerAsync()).AsTask()).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Logger.LogError(ex, "{context}: Unexpected error in OnMonsterDied.", this);
+ }
+ }
+
+ ///
+#pragma warning disable VSTHRD100 // Avoid async void methods
+ protected override async void OnPlayerDied(object? sender, DeathInformation e)
+#pragma warning restore VSTHRD100
+ {
+ try
+ {
+ base.OnPlayerDied(sender, e);
+
+ if (sender is Player player)
+ {
+ await this.FailPlayerAsync(player).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Logger.LogError(ex, "{context}: Unexpected error in OnPlayerDied.", this);
+ }
+ }
+
+ ///
+ protected override async ValueTask OnObjectRemovedFromMapAsync((GameMap Map, ILocateable Object) args)
+ {
+ if (args.Object is Player player)
+ {
+ // Leaving the map during the game, e.g. by warping or disconnecting, fails the event for the player.
+ await this.FailPlayerAsync(player).ConfigureAwait(false);
+ }
+
+ await base.OnObjectRemovedFromMapAsync(args).ConfigureAwait(false);
+ }
+
+ ///
+ protected override async ValueTask GameEndedAsync(ICollection finishers)
+ {
+ this._pathMonsters.Clear();
+ this._iceWalkers.Clear();
+ this._interimChests.Clear();
+
+ var result = this.IsDefenseFailed ? DoppelgangerResult.MonstersReachedMagicCircle : DoppelgangerResult.Success;
+ foreach (var player in finishers)
+ {
+ if (!this._playerResults.TryAdd(player, result))
+ {
+ // The player already got its result, e.g. because it died.
+ continue;
+ }
+
+ await player.InvokeViewPlugInAsync(async p =>
+ {
+ await p.ShowResultAsync(result).ConfigureAwait(false);
+ await p.ShowStateAsync(DoppelgangerState.Ended).ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+
+ if (result == DoppelgangerResult.Success && finishers.FirstOrDefault(player => player.IsAlive) is { } firstFinisher)
+ {
+ this._finalChest = await this.SpawnDestructibleAsync(this._definition.FinalRewardChest, GetAreaAround(firstFinisher.Position, 1)).ConfigureAwait(false);
+ }
+
+ await base.GameEndedAsync(finishers).ConfigureAwait(false);
+ }
+
+ private static DoppelgangerEventDefinition GetEventDefinition(IGameContext gameContext)
+ {
+ return gameContext.FeaturePlugIns.GetPlugIn()?.Configuration
+ ?? DoppelgangerEventDefinition.CreateDefault(gameContext.Configuration);
+ }
+
+ ///
+ /// Determines whether the monster definitions describe the same monster. They're compared
+ /// by their number, because the configured definition may be a different instance than the
+ /// one of the spawned monster.
+ ///
+ private static bool IsSameMonster(MonsterDefinition? first, MonsterDefinition? second)
+ {
+ return first is not null && second is not null && first.Number == second.Number;
+ }
+
+ private static DoppelgangerPathArea GetAreaAround(Point point, byte radius)
+ {
+ // The lower bounds of a path area are exclusive.
+ return new DoppelgangerPathArea(
+ (byte)Math.Max(point.X - radius - 1, 0),
+ (byte)Math.Max(point.Y - radius - 1, 0),
+ (byte)Math.Min(point.X + radius, byte.MaxValue),
+ (byte)Math.Min(point.Y + radius, byte.MaxValue));
+ }
+
+ ///
+ /// Fails the event for the player, if the game is running and it didn't get a result yet.
+ ///
+ /// The player.
+ private async ValueTask FailPlayerAsync(Player player)
+ {
+ if (this.State != MiniGameState.Playing
+ || !this._playerResults.TryAdd(player, DoppelgangerResult.Failed))
+ {
+ return;
+ }
+
+ this.Logger.LogDebug("{context}: The event failed for player {player}.", this, player);
+ await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(DoppelgangerResult.Failed)).ConfigureAwait(false);
+ }
+
+ private async Task RunPlayInfoLoopAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var timer = new PeriodicTimer(this._definition.PlayInfoInterval);
+ do
+ {
+ await this.ShowPlayInfoAsync().ConfigureAwait(false);
+ await this.ShowMonsterPositionAsync().ConfigureAwait(false);
+ }
+ while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false));
+ }
+ catch (OperationCanceledException)
+ {
+ // The game ended.
+ }
+ catch (Exception ex)
+ {
+ this.Logger.LogError(ex, "{context}: Unexpected error in the play info loop.", this);
+ }
+ }
+
+ private async ValueTask ShowPlayInfoAsync()
+ {
+ var mapNumber = (short)this.Map.MapId;
+ var playerPositions = new List<(Player Player, int Position)>();
+
+ // The synchronous part of the action is executed sequentially for all players.
+ await this.ForEachPlayerAsync(player =>
+ {
+ if (player.IsAlive && !this._playerResults.ContainsKey(player))
+ {
+ playerPositions.Add((player, this._definition.GetPathPosition(mapNumber, player.Position)));
+ }
+
+ return Task.CompletedTask;
+ }).ConfigureAwait(false);
+
+ var remainingTime = this.RemainingTime;
+ await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p =>
+ p.ShowPlayInfoAsync(remainingTime, playerPositions)).AsTask()).ConfigureAwait(false);
+ }
+
+ ///
+ /// Shows the position of the most advanced monster on the path, when it changed.
+ /// The ice walkers are not considered, because they're shown separately.
+ ///
+ private async ValueTask ShowMonsterPositionAsync()
+ {
+ var position = this._pathMonsters.Values
+ .Where(intelligence => intelligence.WalksAlongPath)
+ .Select(intelligence => intelligence.PathPosition)
+ .DefaultIfEmpty(0)
+ .Max();
+ if (Interlocked.Exchange(ref this._lastShownMonsterPosition, position) == position)
+ {
+ return;
+ }
+
+ await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p =>
+ p.ShowMonsterPositionAsync(position)).AsTask()).ConfigureAwait(false);
+ }
+
+ private async Task RunMonsterSpawnsAsync(CancellationToken cancellationToken)
+ {
+ if (this._path.Count == 0)
+ {
+ this.Logger.LogError("{context}: No path defined for map {map}, so no monsters will spawn.", this, this.Map.MapId);
+ return;
+ }
+
+ try
+ {
+ var pendingSpawns = this._definition.AdditionalSpawns.OrderBy(spawn => spawn.SpawnTime).ToList();
+ var iceWalkersSpawned = false;
+ using var timer = new PeriodicTimer(this._definition.HerdInterval);
+ do
+ {
+ var elapsed = DateTime.UtcNow - this._gameStartedUtc;
+ await this.SpawnHerdAsync(elapsed).ConfigureAwait(false);
+
+ while (pendingSpawns.Count > 0 && pendingSpawns[0].SpawnTime <= elapsed)
+ {
+ foreach (var monster in pendingSpawns[0].Monsters)
+ {
+ await this.SpawnMonsterAsync(monster, 0, true, true, true).ConfigureAwait(false);
+ }
+
+ pendingSpawns.RemoveAt(0);
+ }
+
+ if (!iceWalkersSpawned && elapsed >= this._definition.IceWalkerSpawnTime)
+ {
+ iceWalkersSpawned = true;
+ _ = Task.Run(() => this.RunIceWalkerMissionAsync(cancellationToken), cancellationToken);
+ }
+ }
+ while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false));
+ }
+ catch (OperationCanceledException)
+ {
+ // The game ended.
+ }
+ catch (Exception ex)
+ {
+ this.Logger.LogError(ex, "{context}: Unexpected error when spawning the monsters.", this);
+ }
+ }
+
+ private async ValueTask SpawnHerdAsync(TimeSpan elapsed)
+ {
+ if (this._definition.HerdMonsters.Count == 0)
+ {
+ return;
+ }
+
+ var count = this._definition.GetHerdBaseCount(elapsed) + Math.Max(0, this.PlayerCount - 1);
+ for (var i = 0; i < count; i++)
+ {
+ var monster = this._definition.HerdMonsters[Rand.NextInt(0, this._definition.HerdMonsters.Count)];
+ var attacksFirst = this._definition.AlwaysAttackingMonsters.Any(m => IsSameMonster(m, monster))
+ || Rand.NextRandomBool(this._definition.AttackFirstChance);
+ await this.SpawnMonsterAsync(monster, 0, true, attacksFirst, true).ConfigureAwait(false);
+ }
+ }
+
+ private async Task RunIceWalkerMissionAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ var count = Math.Max(1, this.PlayerCount);
+ var shownPosition = -1;
+ for (var i = 0; i < count; i++)
+ {
+ var position = Rand.NextInt(this._definition.IceWalkerMinimumPosition, this._definition.IceWalkerMaximumPosition + 1);
+ if (await this.SpawnMonsterAsync(this._definition.IceWalker, position, false, true).ConfigureAwait(false) is { } iceWalker)
+ {
+ this._iceWalkers.TryAdd(iceWalker, 0);
+ shownPosition = shownPosition < 0 ? position : shownPosition;
+ }
+ }
+
+ if (shownPosition < 0)
+ {
+ return;
+ }
+
+ this.Logger.LogDebug("{context}: {count} ice walkers appeared.", this, this._iceWalkers.Count);
+ await this.ShowGoldenMessageAsync(nameof(PlayerMessage.DoppelgangerIceWalkerAppeared)).ConfigureAwait(false);
+ await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowIceWalkerAsync(shownPosition)).AsTask()).ConfigureAwait(false);
+
+ var countdownDuration = this._definition.IceWalkerMissionDuration < IceWalkerCountdownDuration
+ ? this._definition.IceWalkerMissionDuration
+ : IceWalkerCountdownDuration;
+ await Task.Delay(this._definition.IceWalkerMissionDuration - countdownDuration, cancellationToken).ConfigureAwait(false);
+ if (this._iceWalkers.IsEmpty)
+ {
+ return;
+ }
+
+ await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowIceWalkerCountdownAsync()).AsTask()).ConfigureAwait(false);
+ await Task.Delay(countdownDuration, cancellationToken).ConfigureAwait(false);
+
+ var remainingIceWalkers = this._iceWalkers.Keys.ToList();
+ if (remainingIceWalkers.Count == 0)
+ {
+ return;
+ }
+
+ this.Logger.LogDebug("{context}: The ice walkers weren't killed in time, so the following monsters get stronger.", this);
+ Interlocked.Exchange(ref this._isIceWalkerMissionFailed, 1);
+ foreach (var iceWalker in remainingIceWalkers)
+ {
+ await this.RemoveMonsterAsync(iceWalker).ConfigureAwait(false);
+ }
+
+ await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.HideIceWalkerAsync()).AsTask()).ConfigureAwait(false);
+ await this.ShowGoldenMessageAsync(nameof(PlayerMessage.DoppelgangerIceWalkerEscaped)).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // The game ended.
+ }
+ catch (Exception ex)
+ {
+ this.Logger.LogError(ex, "{context}: Unexpected error in the ice walker mission.", this);
+ }
+ }
+
+ private ValueTask SpawnMonsterAsync(MonsterDefinition? monsterDefinition, int pathPosition, bool walksAlongPath, bool attacksFirst, bool isAffectedByMissionFailure = false)
+ {
+ return this.SpawnMonsterAsync(monsterDefinition, this._path[pathPosition], pathPosition, walksAlongPath, attacksFirst, isAffectedByMissionFailure);
+ }
+
+ private async ValueTask SpawnMonsterAsync(MonsterDefinition? monsterDefinition, DoppelgangerPathArea area, int pathPosition, bool walksAlongPath, bool attacksFirst, bool isAffectedByMissionFailure = false)
+ {
+ if (this.CreateSpawnArea(monsterDefinition, area) is not { } spawnArea)
+ {
+ return null;
+ }
+
+ var intelligence = new DoppelgangerMonsterIntelligence(this._path, pathPosition, walksAlongPath, attacksFirst, this.OnMonsterReachedMagicCircleAsync, this.Logger);
+ var monster = new Monster(spawnArea, spawnArea.MonsterDefinition!, this.Map, this.DropGenerator, intelligence, this._gameContext.PlugInManager, this._gameContext.PathFinderPool, this);
+
+ // The multipliers have to be applied before the monster is initialized, which sets its health.
+ var penalty = isAffectedByMissionFailure && Volatile.Read(ref this._isIceWalkerMissionFailed) != 0
+ ? this._definition.IceWalkerMissionFailedMultiplier
+ : 1;
+ this._monsterMultipliers.ApplyTo(monster, penalty);
+ try
+ {
+ monster.Initialize();
+ }
+ catch (InvalidOperationException ex)
+ {
+ this.Logger.LogWarning(ex, "{context}: Couldn't spawn monster {monster} at the path position {pathPosition}.", this, monsterDefinition, pathPosition);
+ monster.Dispose();
+ return null;
+ }
+
+ this._pathMonsters.TryAdd(monster, intelligence);
+ await this.Map.AddAsync(monster).ConfigureAwait(false);
+ monster.OnSpawn();
+ intelligence.Start();
+ return monster;
+ }
+
+ private async ValueTask SpawnInterimChestsAsync(Point position)
+ {
+ var group = new InterimChestGroup();
+ var area = GetAreaAround(position, 2);
+ for (var i = 0; i < this._definition.InterimChestCount; i++)
+ {
+ if (await this.SpawnDestructibleAsync(this._definition.InterimRewardChest, area).ConfigureAwait(false) is { } chest)
+ {
+ group.Chests.Add(chest);
+ this._interimChests.TryAdd(chest, group);
+ }
+ }
+
+ if (group.Chests.Count > 0)
+ {
+ _ = Task.Run(() => this.RemoveUnopenedChestsAsync(group, this.GameEndedToken), this.GameEndedToken);
+ }
+ }
+
+ private async Task RemoveUnopenedChestsAsync(InterimChestGroup group, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await Task.Delay(this._definition.InterimChestDuration, cancellationToken).ConfigureAwait(false);
+ foreach (var chest in group.Chests)
+ {
+ if (this._interimChests.TryRemove(chest, out _))
+ {
+ await this.RemoveNpcAsync(chest).ConfigureAwait(false);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // The game ended.
+ }
+ catch (Exception ex)
+ {
+ this.Logger.LogError(ex, "{context}: Unexpected error when removing the unopened chests.", this);
+ }
+ }
+
+ private async ValueTask DropChestItemsAsync(Player player, Destructible chest)
+ {
+ var (items, _) = await this.DropGenerator.GenerateItemDropsAsync(chest.Definition, 0, player).ConfigureAwait(false);
+ var owners = player.Party?.PartyList.AsEnumerable() ?? player.GetAsEnumerable();
+ var isFirstItem = true;
+ foreach (var item in items)
+ {
+ var position = isFirstItem ? chest.Position : this.Map.Terrain.GetRandomCoordinate(chest.Position, 2);
+ isFirstItem = false;
+ var droppedItem = new DroppedItem(item, position, this.Map, null, owners);
+ await this.Map.AddAsync(droppedItem).ConfigureAwait(false);
+ }
+ }
+
+ private async ValueTask SpawnDestructibleAsync(MonsterDefinition? definition, DoppelgangerPathArea area)
+ {
+ if (this.CreateSpawnArea(definition, area) is not { } spawnArea)
+ {
+ return null;
+ }
+
+ spawnArea.MaximumHealthOverride = ChestHealth;
+
+ var destructible = new Destructible(spawnArea, spawnArea.MonsterDefinition!, this.Map, this, this.DropGenerator, this._gameContext.PlugInManager);
+ try
+ {
+ destructible.Initialize();
+ }
+ catch (InvalidOperationException ex)
+ {
+ this.Logger.LogWarning(ex, "{context}: Failed to spawn {definition} around {area}.", this, definition, area);
+ destructible.Dispose();
+ return null;
+ }
+
+ await this.Map.AddAsync(destructible).ConfigureAwait(false);
+ destructible.OnSpawn();
+ return destructible;
+ }
+
+ private MonsterSpawnArea? CreateSpawnArea(MonsterDefinition? monsterDefinition, DoppelgangerPathArea area)
+ {
+ if (monsterDefinition is null)
+ {
+ this.Logger.LogWarning("{context}: A monster of the event isn't configured.", this);
+ return null;
+ }
+
+ return new MonsterSpawnArea
+ {
+ GameMap = this.Map.Definition,
+ MonsterDefinition = monsterDefinition,
+ SpawnTrigger = SpawnTrigger.OnceAtEventStart,
+ Quantity = 1,
+ X1 = (byte)(area.X1 + 1),
+ X2 = area.X2,
+ Y1 = (byte)(area.Y1 + 1),
+ Y2 = area.Y2,
+ };
+ }
+
+ private async ValueTask RemoveNpcAsync(NonPlayerCharacter npc)
+ {
+ await this.Map.RemoveAsync(npc).ConfigureAwait(false);
+ npc.Dispose();
+ }
+
+ ///
+ /// Gets the multipliers for the monsters, depending on the highest level of the players and their number.
+ ///
+ private MonsterMultipliers GetMonsterMultipliers(ICollection players)
+ {
+ var playerLevel = players
+ .Select(player => (int)((player.Attributes?[Stats.Level] ?? 0) + (player.Attributes?[Stats.MasterLevel] ?? 0)))
+ .DefaultIfEmpty(1)
+ .Max();
+ if (this._definition.GetMonsterScaling(playerLevel) is not { } scaling)
+ {
+ return MonsterMultipliers.None;
+ }
+
+ var index = Math.Clamp(players.Count, 1, 5) - 1;
+ var multipliers = new MonsterMultipliers(
+ GetMultiplier(scaling.LevelMultipliers, index),
+ GetMultiplier(scaling.HealthMultipliers, index),
+ GetMultiplier(scaling.DamageMultipliers, index),
+ GetMultiplier(scaling.DefenseMultipliers, index));
+ this.Logger.LogDebug("{context}: Monster multipliers for player level {playerLevel} and {playerCount} players: {multipliers}", this, playerLevel, players.Count, multipliers);
+ return multipliers;
+
+ static float GetMultiplier(IList multipliers, int index) =>
+ multipliers.Count == 0 ? 1 : multipliers[Math.Min(index, multipliers.Count - 1)];
+ }
+
+ private async ValueTask OnMonsterReachedMagicCircleAsync(Monster monster)
+ {
+ await this.RemoveMonsterAsync(monster).ConfigureAwait(false);
+ await this.RegisterMonsterReachedMagicCircleAsync().ConfigureAwait(false);
+ }
+
+ private async ValueTask RemoveMonsterAsync(Monster monster)
+ {
+ this._pathMonsters.TryRemove(monster, out _);
+ this._iceWalkers.TryRemove(monster, out _);
+ await this.RemoveNpcAsync(monster).ConfigureAwait(false);
+ }
+
+ ///
+ /// The multipliers for the monsters of a game.
+ ///
+ /// The multiplier for the level.
+ /// The multiplier for the health.
+ /// The multiplier for the damage.
+ /// The multiplier for the defense.
+ private sealed record MonsterMultipliers(float Level, float Health, float Damage, float Defense)
+ {
+ ///
+ /// Gets the multipliers which don't change anything.
+ ///
+ public static MonsterMultipliers None { get; } = new(1, 1, 1, 1);
+
+ ///
+ /// Applies the multipliers to the monster.
+ ///
+ /// The monster.
+ /// An additional multiplier for the health, damage and defense.
+ public void ApplyTo(Monster monster, float penalty)
+ {
+ Multiply(monster, Stats.Level, this.Level);
+ Multiply(monster, Stats.MaximumHealth, this.Health * penalty);
+ Multiply(monster, Stats.MinimumPhysBaseDmg, this.Damage * penalty);
+ Multiply(monster, Stats.MaximumPhysBaseDmg, this.Damage * penalty);
+ Multiply(monster, Stats.DefenseBase, this.Defense * penalty);
+ }
+
+ private static void Multiply(Monster monster, AttributeDefinition attribute, float multiplier)
+ {
+ if (Math.Abs(multiplier - 1) > 0.001f)
+ {
+ monster.Attributes.AddElement(new SimpleElement(multiplier, AggregateType.Multiplicate), attribute);
+ }
+ }
+ }
+
+ ///
+ /// A group of interim reward chests, which appeared together. Only one of them can be opened.
+ ///
+ private sealed class InterimChestGroup
+ {
+ private int _isOpened;
+
+ ///
+ /// Gets the chests of the group.
+ ///
+ public List Chests { get; } = new();
+
+ ///
+ /// Tries to open the group. It succeeds only once.
+ ///
+ /// true, if the group was opened; otherwise, false.
+ public bool TryOpen() => Interlocked.Exchange(ref this._isOpened, 1) == 0;
+ }
+}
diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs
new file mode 100644
index 0000000000..9c9760d215
--- /dev/null
+++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs
@@ -0,0 +1,406 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// Describes the run of the doppelganger event.
+///
+///
+/// It's configured at the , so it can be adapted in the admin panel.
+///
+public class DoppelgangerEventDefinition
+{
+ private const int MaximumPlayerCount = 5;
+ private const int ScalingLevelStep = 50;
+ private const int ScalingMaximumPlayerLevel = 800;
+
+ ///
+ /// Gets or sets the number of monsters which may reach the magic circle until the event fails.
+ ///
+ public int MaximumGoalCount { get; set; } = 3;
+
+ ///
+ /// Gets or sets the interval in which the remaining time and the positions of the players
+ /// are sent to the clients. The client doesn't count down the time by itself.
+ ///
+ public TimeSpan PlayInfoInterval { get; set; } = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Gets or sets the interval in which a herd of monsters spawns at the start of the path.
+ ///
+ public TimeSpan HerdInterval { get; set; } = TimeSpan.FromSeconds(3);
+
+ ///
+ /// Gets or sets the monsters of which a herd consists. Each monster of a herd is chosen randomly.
+ ///
+ public IList HerdMonsters { get; set; } = new List();
+
+ ///
+ /// Gets or sets the base count of monsters of a herd, depending on the elapsed game time.
+ /// One monster is added for each player in the event, starting with the second one.
+ ///
+ public IList HerdSizes { get; set; } = new List();
+
+ ///
+ /// Gets or sets the chance (between 0 and 1) that a monster of a herd attacks players in its range,
+ /// without being attacked first.
+ ///
+ public double AttackFirstChance { get; set; } = 0.7;
+
+ ///
+ /// Gets or sets the monsters which always attack players in their range.
+ ///
+ public IList AlwaysAttackingMonsters { get; set; } = new List();
+
+ ///
+ /// Gets or sets the additional monsters which spawn once at the start of the path at a specific time.
+ ///
+ public IList AdditionalSpawns { get; set; } = new List();
+
+ ///
+ /// Gets or sets the ice walker monster.
+ ///
+ public MonsterDefinition? IceWalker { get; set; }
+
+ ///
+ /// Gets or sets the elapsed game time after which the ice walkers appear. One ice walker
+ /// appears for each player in the event. They stay at a random position on the path.
+ ///
+ public TimeSpan IceWalkerSpawnTime { get; set; } = TimeSpan.FromMinutes(6).Subtract(TimeSpan.FromSeconds(5));
+
+ ///
+ /// Gets or sets the time in which the players have to kill the ice walkers. Otherwise, they disappear.
+ ///
+ public TimeSpan IceWalkerMissionDuration { get; set; } = TimeSpan.FromSeconds(60);
+
+ ///
+ /// Gets or sets the lowest position index on the path at which an ice walker appears.
+ ///
+ public int IceWalkerMinimumPosition { get; set; } = 3;
+
+ ///
+ /// Gets or sets the highest position index on the path at which an ice walker appears.
+ ///
+ public int IceWalkerMaximumPosition { get; set; } = 18;
+
+ ///
+ /// Gets or sets the multiplier for the health, damage and defense of the monsters of the herds and
+ /// additional spawns, which spawn after the players failed to kill the ice walkers in time.
+ ///
+ public float IceWalkerMissionFailedMultiplier { get; set; } = 2;
+
+ ///
+ /// Gets or sets the multipliers for the monsters, depending on the highest level of the players and their number.
+ /// The level of a player includes its master level.
+ ///
+ public IList MonsterScalings { get; set; } = new List();
+
+ ///
+ /// Gets or sets the monsters which leave interim reward chests behind when they die.
+ ///
+ public IList InterimChestMonsters { get; set; } = new List();
+
+ ///
+ /// Gets or sets the interim reward chest.
+ ///
+ public MonsterDefinition? InterimRewardChest { get; set; }
+
+ ///
+ /// Gets or sets the number of interim reward chests which appear together. Only one of them can be opened.
+ ///
+ public int InterimChestCount { get; set; } = 3;
+
+ ///
+ /// Gets or sets the time after which interim reward chests disappear, when none of them was opened.
+ ///
+ public TimeSpan InterimChestDuration { get; set; } = TimeSpan.FromSeconds(60);
+
+ ///
+ /// Gets or sets the chance (between 0 and 1) that larvae come out of an interim reward chest instead of items.
+ ///
+ public double LarvaChance { get; set; } = 0.6;
+
+ ///
+ /// Gets or sets the larva monster. One larva comes out of a chest for each player which started the event.
+ ///
+ public MonsterDefinition? Larva { get; set; }
+
+ ///
+ /// Gets or sets the final reward chest, which appears when the players successfully defended the magic circle.
+ ///
+ public MonsterDefinition? FinalRewardChest { get; set; }
+
+ ///
+ /// Gets or sets the paths of the monsters, one for each event map.
+ ///
+ public IList Paths { get; set; } = new List();
+
+ ///
+ /// Creates the default definition of the event.
+ ///
+ /// The game configuration, which contains the monsters of the event.
+ /// The created definition.
+ public static DoppelgangerEventDefinition CreateDefault(GameConfiguration gameConfiguration)
+ {
+ MonsterDefinition? Monster(short number) => gameConfiguration.Monsters.FirstOrDefault(monster => monster.Number == number);
+
+ IList Monsters(params short[] numbers) => numbers
+ .Select(Monster)
+ .Where(monster => monster is not null)
+ .Select(monster => monster!)
+ .ToList();
+
+ const short terribleButcher = 529;
+ const short madButcher = 530;
+ const short doppelganger = 533;
+ const short doppelgangerDarkLord = 538;
+
+ return new DoppelgangerEventDefinition
+ {
+ // The dark lord clone (538) only appears as additional spawn.
+ HerdMonsters = Monsters(doppelganger, 534, 535, 536, 537, 539),
+ AlwaysAttackingMonsters = Monsters(doppelganger),
+ InterimChestMonsters = Monsters(terribleButcher, madButcher),
+ IceWalker = Monster(531),
+ Larva = Monster(532),
+ InterimRewardChest = Monster(541),
+ FinalRewardChest = Monster(542),
+ HerdSizes =
+ [
+ new DoppelgangerHerdSize { StartsAfter = TimeSpan.Zero, BaseCount = 1 },
+ new DoppelgangerHerdSize { StartsAfter = TimeSpan.FromMinutes(3), BaseCount = 2 },
+ new DoppelgangerHerdSize { StartsAfter = TimeSpan.FromMinutes(6), BaseCount = 3 },
+ ],
+ AdditionalSpawns =
+ [
+ new DoppelgangerMonsterSpawn { SpawnTime = TimeSpan.FromMinutes(1), Monsters = Monsters(madButcher, doppelgangerDarkLord) },
+ new DoppelgangerMonsterSpawn { SpawnTime = TimeSpan.FromMinutes(4), Monsters = Monsters(madButcher, doppelgangerDarkLord) },
+ new DoppelgangerMonsterSpawn { SpawnTime = TimeSpan.FromMinutes(7), Monsters = Monsters(terribleButcher, doppelgangerDarkLord) },
+ ],
+ MonsterScalings = CreateDefaultMonsterScalings(),
+ Paths = CreateDefaultPaths(),
+ };
+ }
+
+ ///
+ /// Gets the multipliers for the monsters, which apply to the specified highest player level.
+ ///
+ /// The highest level of the players, including the master level.
+ /// The multipliers, or null if there are none.
+ public DoppelgangerMonsterScaling? GetMonsterScaling(int playerLevel)
+ {
+ return this.MonsterScalings
+ .Where(scaling => scaling.MaximumPlayerLevel >= playerLevel)
+ .MinBy(scaling => scaling.MaximumPlayerLevel)
+ ?? this.MonsterScalings.MaxBy(scaling => scaling.MaximumPlayerLevel);
+ }
+
+ ///
+ /// Gets the base count of monsters of a herd after the specified elapsed game time.
+ ///
+ /// The elapsed game time.
+ /// The base count of monsters of a herd.
+ public int GetHerdBaseCount(TimeSpan elapsed)
+ {
+ return this.HerdSizes
+ .Where(size => size.StartsAfter <= elapsed)
+ .MaxBy(size => size.StartsAfter)?.BaseCount ?? 0;
+ }
+
+ ///
+ /// Gets the position index of the point on the path of the specified map.
+ ///
+ /// The map number.
+ /// The point.
+ ///
+ /// The index of the first area of the path which contains the point, or 0 when there is
+ /// no such area. The index 0 is the start of the monsters and the last index is the magic circle.
+ ///
+ public int GetPathPosition(short mapNumber, Point point)
+ {
+ if (this.Paths.FirstOrDefault(path => path.MapNumber == mapNumber) is not { } path)
+ {
+ return 0;
+ }
+
+ for (var i = 0; i < path.Areas.Count; i++)
+ {
+ if (path.Areas[i].Contains(point))
+ {
+ return i;
+ }
+ }
+
+ return 0;
+ }
+
+ ///
+ /// Creates the paths of the event maps. Each path consists of 23 areas,
+ /// from the start of the monsters (index 0) to the magic circle (index 22), which is
+ /// located next to the entrance of the players.
+ ///
+ private static IList CreateDefaultPaths()
+ {
+ return
+ [
+ new DoppelgangerPath
+ {
+ MapNumber = 65, // Doppelganger 1 (Ice)
+ Areas =
+ [
+ new(220, 99, 230, 107),
+ new(218, 95, 230, 100),
+ new(217, 91, 225, 96),
+ new(217, 87, 224, 92),
+ new(217, 83, 224, 88),
+ new(217, 79, 224, 84),
+ new(217, 75, 224, 80),
+ new(215, 72, 225, 76),
+ new(212, 70, 224, 73),
+ new(209, 67, 222, 71),
+ new(206, 64, 218, 68),
+ new(202, 62, 216, 65),
+ new(200, 59, 213, 63),
+ new(196, 57, 210, 60),
+ new(193, 55, 208, 58),
+ new(192, 51, 203, 56),
+ new(193, 48, 201, 52),
+ new(193, 44, 201, 49),
+ new(193, 40, 201, 45),
+ new(193, 37, 201, 41),
+ new(194, 34, 201, 38),
+ new(192, 30, 202, 35),
+ new(192, 24, 202, 31),
+ ],
+ },
+ new DoppelgangerPath
+ {
+ MapNumber = 66, // Doppelganger 2 (Fire)
+ Areas =
+ [
+ new(108, 179, 119, 186),
+ new(108, 174, 119, 180),
+ new(109, 169, 117, 175),
+ new(109, 163, 117, 170),
+ new(109, 158, 117, 164),
+ new(109, 153, 117, 159),
+ new(109, 147, 118, 154),
+ new(109, 142, 119, 148),
+ new(110, 137, 120, 143),
+ new(112, 132, 122, 138),
+ new(114, 127, 126, 133),
+ new(116, 123, 131, 128),
+ new(121, 119, 133, 124),
+ new(124, 114, 137, 120),
+ new(127, 109, 139, 115),
+ new(130, 104, 140, 110),
+ new(132, 98, 141, 105),
+ new(133, 93, 141, 99),
+ new(133, 88, 141, 94),
+ new(133, 83, 141, 89),
+ new(133, 78, 141, 84),
+ new(133, 74, 143, 79),
+ new(133, 66, 143, 75),
+ ],
+ },
+ new DoppelgangerPath
+ {
+ MapNumber = 67, // Doppelganger 3 (Water)
+ Areas =
+ [
+ new(107, 149, 114, 155),
+ new(105, 144, 114, 150),
+ new(105, 140, 113, 145),
+ new(105, 135, 113, 141),
+ new(105, 131, 113, 136),
+ new(105, 127, 113, 132),
+ new(105, 123, 114, 128),
+ new(105, 119, 113, 124),
+ new(105, 115, 113, 120),
+ new(105, 111, 113, 116),
+ new(105, 107, 113, 112),
+ new(105, 103, 113, 108),
+ new(105, 99, 113, 104),
+ new(105, 95, 113, 100),
+ new(105, 91, 113, 96),
+ new(105, 86, 113, 92),
+ new(105, 82, 113, 87),
+ new(105, 78, 113, 83),
+ new(105, 74, 113, 79),
+ new(105, 70, 113, 75),
+ new(105, 66, 113, 71),
+ new(104, 62, 115, 67),
+ new(104, 55, 115, 63),
+ ],
+ },
+ new DoppelgangerPath
+ {
+ MapNumber = 68, // Doppelganger 4 (Ground)
+ Areas =
+ [
+ new(38, 105, 48, 113),
+ new(38, 99, 47, 106),
+ new(38, 94, 45, 100),
+ new(38, 89, 45, 95),
+ new(34, 84, 51, 90),
+ new(40, 80, 54, 85),
+ new(44, 76, 61, 81),
+ new(48, 71, 65, 77),
+ new(53, 66, 65, 72),
+ new(57, 62, 67, 67),
+ new(59, 57, 66, 63),
+ new(59, 50, 67, 58),
+ new(66, 50, 72, 57),
+ new(71, 49, 77, 57),
+ new(76, 49, 84, 57),
+ new(83, 50, 89, 57),
+ new(88, 46, 98, 57),
+ new(90, 41, 99, 47),
+ new(91, 35, 98, 42),
+ new(91, 29, 98, 36),
+ new(91, 23, 99, 30),
+ new(91, 19, 101, 24),
+ new(91, 10, 101, 20),
+ ],
+ },
+ ];
+ }
+
+ ///
+ /// Creates simple default multipliers of the monsters, which are meant to be adjusted by the
+ /// server administrator. There is one entry for every 50 player levels (including the master level).
+ /// The health, damage and defense grow linearly with the player level, and each additional player
+ /// adds to them.
+ ///
+ private static IList CreateDefaultMonsterScalings()
+ {
+ var scalings = new List();
+ for (var playerLevel = ScalingLevelStep; playerLevel <= ScalingMaximumPlayerLevel; playerLevel += ScalingLevelStep)
+ {
+ var level = 1 + (playerLevel / 100f);
+ var health = Math.Max(1, playerLevel / 10f);
+ var damage = Math.Max(1, playerLevel / 50f);
+ var defense = Math.Max(1, playerLevel / 40f);
+ scalings.Add(new DoppelgangerMonsterScaling
+ {
+ MaximumPlayerLevel = playerLevel,
+ LevelMultipliers = PerPlayerCount(level, 0),
+ HealthMultipliers = PerPlayerCount(health, 0.5f),
+ DamageMultipliers = PerPlayerCount(damage, 0.2f),
+ DefenseMultipliers = PerPlayerCount(defense, 0.2f),
+ });
+ }
+
+ return scalings;
+
+ static IList PerPlayerCount(float multiplier, float increasePerAdditionalPlayer) =>
+ Enumerable.Range(0, MaximumPlayerCount)
+ .Select(additionalPlayers => MathF.Round(multiplier * (1 + (additionalPlayers * increasePerAdditionalPlayer)), 2))
+ .ToList();
+ }
+}
diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerFeaturePlugIn.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerFeaturePlugIn.cs
new file mode 100644
index 0000000000..60577a4026
--- /dev/null
+++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerFeaturePlugIn.cs
@@ -0,0 +1,27 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// The feature plug-in of the doppelganger event. Its configuration describes the run of the event,
+/// e.g. the monsters, their paths and multipliers, so that it can be adapted in the admin panel.
+///
+///
+/// The configuration is until it's either seeded by the data initialization
+/// or filled in by an administrator, because the monsters can only be referenced when the game
+/// configuration is known. The falls back to
+/// in that case.
+///
+[PlugIn]
+[Display(Name = nameof(PlugInResources.DoppelgangerFeaturePlugIn_Name), Description = nameof(PlugInResources.DoppelgangerFeaturePlugIn_Description), ResourceType = typeof(PlugInResources))]
+[Guid("A4C7E2D9-3B5F-4E81-9C06-7D1F2B8E5A43")]
+public class DoppelgangerFeaturePlugIn : IFeaturePlugIn, ISupportCustomConfiguration
+{
+ ///
+ public DoppelgangerEventDefinition? Configuration { get; set; }
+}
diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerHerdSize.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerHerdSize.cs
new file mode 100644
index 0000000000..0888dea9e9
--- /dev/null
+++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerHerdSize.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.MiniGames.Doppelganger;
+
+///
+/// The size of the herds of the doppelganger event, starting at a specific game time.
+///
+public class DoppelgangerHerdSize
+{
+ ///
+ /// Gets or sets the elapsed game time after which this size applies.
+ ///
+ public TimeSpan StartsAfter { get; set; }
+
+ ///
+ /// Gets or sets the base count of monsters of a herd.
+ ///
+ public int BaseCount { get; set; }
+}
diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs
new file mode 100644
index 0000000000..2eef7cc5dd
--- /dev/null
+++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs
@@ -0,0 +1,274 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+
+using System.Threading;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.NPC;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// The intelligence of the monsters of the doppelganger event.
+/// They walk along the path towards the magic circle and only attack players
+/// which are in their attack range, so that they don't leave the path.
+///
+///
+/// Unlike the , it keeps running when no player
+/// observes the monster, because the monsters have to walk towards the magic circle anyway.
+///
+public sealed class DoppelgangerMonsterIntelligence : INpcIntelligence, IDisposable
+{
+ private readonly IList _path;
+ private readonly Func _onMagicCircleReached;
+ private readonly ILogger _logger;
+ private Timer? _timer;
+ private Monster? _monster;
+ private IAttackable? _attacker;
+ private int _pathPosition;
+ private int _hasReachedMagicCircle;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The areas of the path.
+ /// The position index on the path where the monster spawns.
+ /// If set to true, the monster walks along the path; otherwise, it stays at its position.
+ /// If set to true, the monster attacks players in its range; otherwise, only players which attacked it.
+ /// The callback which is called when the monster reached the magic circle.
+ /// The logger.
+ public DoppelgangerMonsterIntelligence(
+ IList path,
+ int pathPosition,
+ bool walksAlongPath,
+ bool attacksFirst,
+ Func onMagicCircleReached,
+ ILogger logger)
+ {
+ this._path = path;
+ this._pathPosition = pathPosition;
+ this.WalksAlongPath = walksAlongPath;
+ this.AttacksFirst = attacksFirst;
+ this._onMagicCircleReached = onMagicCircleReached;
+ this._logger = logger;
+ }
+
+ ///
+ /// Gets a value indicating whether the monster walks along the path.
+ ///
+ public bool WalksAlongPath { get; }
+
+ ///
+ /// Gets a value indicating whether the monster attacks players in its range without being attacked first.
+ ///
+ public bool AttacksFirst { get; }
+
+ ///
+ /// Gets the current position index of the monster on the path.
+ ///
+ public int PathPosition => Volatile.Read(ref this._pathPosition);
+
+ ///
+ public NonPlayerCharacter Npc
+ {
+ get => this.Monster;
+ set => this.Monster = (Monster)value;
+ }
+
+ ///
+ /// Gets or sets the monster.
+ ///
+ public Monster Monster
+ {
+ get => this._monster ?? throw new InvalidOperationException("Instance is not initialized with a Monster yet");
+ set => this._monster = value;
+ }
+
+ ///
+ /// The magic circle may be located in a safezone.
+ public bool CanWalkOnSafezone => true;
+
+ ///
+ public void RegisterHit(IAttacker attacker)
+ {
+ if (attacker is IAttackable attackable)
+ {
+ this._attacker = attackable;
+ }
+ }
+
+ ///
+ public void Start()
+ {
+ this._timer ??= new Timer(state => _ = this.SafeTickAsync(), null, this.Monster.Definition.AttackDelay, this.Monster.Definition.AttackDelay);
+ }
+
+ ///
+ ///
+ /// The monster keeps walking when no player observes it, so pausing is ignored.
+ ///
+ public void Pause()
+ {
+ // intentionally left blank.
+ }
+
+ ///
+ public bool CanWalkOn(Point target)
+ {
+ return (this.Monster.CurrentMap.Terrain.AIgrid[target.X, target.Y] & 1) == 1;
+ }
+
+ ///
+ public void Dispose()
+ {
+ this._timer?.Dispose();
+ this._timer = null;
+ }
+
+ ///
+ /// Gets the center of the area, which is the target of the monsters walking to this area.
+ ///
+ /// The area.
+ /// The center of the area.
+ internal static Point GetCenter(DoppelgangerPathArea area)
+ {
+ return new Point((byte)(area.X1 + ((area.X2 - area.X1) / 2)), (byte)(area.Y1 + ((area.Y2 - area.Y1) / 2)));
+ }
+
+ ///
+ /// Executes one step of the intelligence. It's called periodically by a timer.
+ ///
+ internal async ValueTask TickAsync()
+ {
+ if (this._monster is not { IsAlive: true } monster
+ || Volatile.Read(ref this._hasReachedMagicCircle) != 0)
+ {
+ return;
+ }
+
+ // Only the monsters which walk along the path can reach the magic circle. Others, like the
+ // larvae of a chest, might just be standing next to it.
+ if (this.WalksAlongPath && this.UpdatePathPosition(monster.Position) == this._path.Count - 1)
+ {
+ if (Interlocked.Exchange(ref this._hasReachedMagicCircle, 1) == 0)
+ {
+ this.Dispose();
+ await this._onMagicCircleReached.Invoke(monster).ConfigureAwait(false);
+ }
+
+ return;
+ }
+
+ if (monster.IsWalking
+ || monster.Attributes[Stats.IsStunned] > 0
+ || monster.Attributes[Stats.IsAsleep] > 0)
+ {
+ return;
+ }
+
+ if (await this.SearchTargetAsync(monster).ConfigureAwait(false) is { } target)
+ {
+ await monster.AttackAsync(target).ConfigureAwait(false);
+ return;
+ }
+
+ if (this.WalksAlongPath && monster.Attributes[Stats.IsFrozen] <= 0)
+ {
+ await monster.WalkToAsync(this.GetWalkTarget(this._path[this.PathPosition + 1])).ConfigureAwait(false);
+ }
+ }
+
+ private async Task SafeTickAsync()
+ {
+ try
+ {
+ await this.TickAsync().ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // expected during shutdown.
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Unexpected error in the doppelganger monster intelligence of {monster}.", this._monster);
+ }
+ }
+
+ ///
+ /// Updates the position index on the path. The index only increases, when the monster
+ /// entered one of the next areas, because the areas overlap.
+ ///
+ private int UpdatePathPosition(Point position)
+ {
+ var current = this.PathPosition;
+ for (var i = this._path.Count - 1; i > current; i--)
+ {
+ if (this._path[i].Contains(position))
+ {
+ Volatile.Write(ref this._pathPosition, i);
+ return i;
+ }
+ }
+
+ return current;
+ }
+
+ private async ValueTask SearchTargetAsync(Monster monster)
+ {
+ if (this._attacker is { } attacker && this.IsValidTarget(monster, attacker))
+ {
+ return attacker;
+ }
+
+ this._attacker = null;
+ if (!this.AttacksFirst)
+ {
+ return null;
+ }
+
+ List candidates;
+ using (await monster.ObserverLock.ReaderLockAsync())
+ {
+ candidates = monster.Observers.OfType().Where(candidate => this.IsValidTarget(monster, candidate)).ToList();
+ }
+
+ return candidates.MinBy(candidate => candidate.GetDistanceTo(monster));
+ }
+
+ private bool IsValidTarget(Monster monster, IAttackable target)
+ {
+ return target.IsActive()
+ && target is not Player { IsInvisible: true }
+ && !target.IsAtSafezone()
+ && target.IsInRange(monster.Position, monster.Definition.AttackRange)
+ && monster.HasLineOfSightTo(target);
+ }
+
+ ///
+ /// Gets the point to which the monster walks to reach the area. It's the center of the
+ /// area, or another walkable point of it, if the center isn't walkable.
+ ///
+ private Point GetWalkTarget(DoppelgangerPathArea area)
+ {
+ var center = GetCenter(area);
+ if (this.CanWalkOn(center))
+ {
+ return center;
+ }
+
+ for (var x = area.X1 + 1; x <= area.X2; x++)
+ {
+ for (var y = area.Y1 + 1; y <= area.Y2; y++)
+ {
+ var point = new Point((byte)x, (byte)y);
+ if (this.CanWalkOn(point))
+ {
+ return point;
+ }
+ }
+ }
+
+ return center;
+ }
+}
diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterScaling.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterScaling.cs
new file mode 100644
index 0000000000..c26c6bb58e
--- /dev/null
+++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterScaling.cs
@@ -0,0 +1,37 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+
+///
+/// The multipliers for the monsters of the doppelganger event, which apply up to a player level.
+/// Each list contains one multiplier for each number of players, starting with one player.
+///
+public class DoppelgangerMonsterScaling
+{
+ ///
+ /// Gets or sets the highest player level (including the master level) for which the multipliers apply.
+ ///
+ public int MaximumPlayerLevel { get; set; }
+
+ ///
+ /// Gets or sets the multipliers for the level of the monsters.
+ ///
+ public IList LevelMultipliers { get; set; } = new List();
+
+ ///
+ /// Gets or sets the multipliers for the health of the monsters.
+ ///
+ public IList HealthMultipliers { get; set; } = new List();
+
+ ///
+ /// Gets or sets the multipliers for the damage of the monsters.
+ ///
+ public IList DamageMultipliers { get; set; } = new List();
+
+ ///
+ /// Gets or sets the multipliers for the defense of the monsters.
+ ///
+ public IList DefenseMultipliers { get; set; } = new List();
+}
diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterSpawn.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterSpawn.cs
new file mode 100644
index 0000000000..b1cd4a3131
--- /dev/null
+++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterSpawn.cs
@@ -0,0 +1,24 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+
+using MUnique.OpenMU.DataModel.Configuration;
+
+///
+/// Monsters which spawn once at the start of the path of the doppelganger event at a specific game time.
+/// These monsters always attack players in their range.
+///
+public class DoppelgangerMonsterSpawn
+{
+ ///
+ /// Gets or sets the elapsed game time at which the monsters spawn.
+ ///
+ public TimeSpan SpawnTime { get; set; }
+
+ ///
+ /// Gets or sets the monsters which spawn.
+ ///
+ public IList Monsters { get; set; } = new List();
+}
diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPath.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPath.cs
new file mode 100644
index 0000000000..6a14d5a719
--- /dev/null
+++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPath.cs
@@ -0,0 +1,22 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+
+///
+/// The path of the monsters towards the magic circle on one doppelganger event map.
+///
+public class DoppelgangerPath
+{
+ ///
+ /// Gets or sets the number of the map.
+ ///
+ public short MapNumber { get; set; }
+
+ ///
+ /// Gets or sets the areas of the path, from the start of the monsters (index 0)
+ /// to the magic circle (last index).
+ ///
+ public IList Areas { get; set; } = new List();
+}
diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPathArea.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPathArea.cs
new file mode 100644
index 0000000000..c33775fe29
--- /dev/null
+++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPathArea.cs
@@ -0,0 +1,73 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// A rectangular area of the path, which the monsters of the doppelganger event walk
+/// along towards the magic circle.
+///
+///
+/// The areas of a path overlap. The lower bounds are exclusive and the upper bounds are inclusive.
+///
+public class DoppelgangerPathArea
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public DoppelgangerPathArea()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The lower x coordinate (exclusive).
+ /// The lower y coordinate (exclusive).
+ /// The upper x coordinate (inclusive).
+ /// The upper y coordinate (inclusive).
+ public DoppelgangerPathArea(byte x1, byte y1, byte x2, byte y2)
+ {
+ this.X1 = x1;
+ this.Y1 = y1;
+ this.X2 = x2;
+ this.Y2 = y2;
+ }
+
+ ///
+ /// Gets or sets the lower x coordinate (exclusive).
+ ///
+ public byte X1 { get; set; }
+
+ ///
+ /// Gets or sets the lower y coordinate (exclusive).
+ ///
+ public byte Y1 { get; set; }
+
+ ///
+ /// Gets or sets the upper x coordinate (inclusive).
+ ///
+ public byte X2 { get; set; }
+
+ ///
+ /// Gets or sets the upper y coordinate (inclusive).
+ ///
+ public byte Y2 { get; set; }
+
+ ///
+ /// Determines whether the area contains the specified point.
+ ///
+ /// The point.
+ /// true, if the area contains the point; otherwise, false.
+ public bool Contains(Point point)
+ {
+ return point.X > this.X1 && point.X <= this.X2
+ && point.Y > this.Y1 && point.Y <= this.Y2;
+ }
+
+ ///
+ public override string ToString() => $"({this.X1}, {this.Y1}) - ({this.X2}, {this.Y2})";
+}
diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerResult.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerResult.cs
new file mode 100644
index 0000000000..eea6a6ea84
--- /dev/null
+++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerResult.cs
@@ -0,0 +1,26 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+
+///
+/// The result of the doppelganger event for a player, matching the values expected by the client.
+///
+public enum DoppelgangerResult : byte
+{
+ ///
+ /// The party successfully defended the magic circle.
+ ///
+ Success = 0,
+
+ ///
+ /// The player failed, e.g. because the character died or left the event map.
+ ///
+ Failed = 1,
+
+ ///
+ /// The defense failed, because too many monsters reached the magic circle.
+ ///
+ MonstersReachedMagicCircle = 2,
+}
diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerState.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerState.cs
new file mode 100644
index 0000000000..9dacbf5903
--- /dev/null
+++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerState.cs
@@ -0,0 +1,31 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+
+///
+/// The state of the doppelganger event, matching the values expected by the client.
+///
+public enum DoppelgangerState : byte
+{
+ ///
+ /// The event is waiting for the party members to enter.
+ ///
+ Waiting = 0,
+
+ ///
+ /// The entrance is closed and the event is about to start.
+ ///
+ Ready = 1,
+
+ ///
+ /// The event is running.
+ ///
+ Playing = 2,
+
+ ///
+ /// The event has ended.
+ ///
+ Ended = 3,
+}
diff --git a/src/GameLogic/MiniGames/Doppelganger/IDoppelgangerEventViewPlugIn.cs b/src/GameLogic/MiniGames/Doppelganger/IDoppelgangerEventViewPlugIn.cs
new file mode 100644
index 0000000000..bac1eefbb7
--- /dev/null
+++ b/src/GameLogic/MiniGames/Doppelganger/IDoppelgangerEventViewPlugIn.cs
@@ -0,0 +1,74 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+
+using MUnique.OpenMU.GameLogic.Views;
+
+///
+/// View plugin interface for the doppelganger event.
+///
+///
+/// Positions are indexes on the path of the monsters, between 0 (start)
+/// and (magic circle).
+///
+public interface IDoppelgangerEventViewPlugIn : IViewPlugIn
+{
+ ///
+ /// Gets the maximum position index on the path, which is the position of the magic circle.
+ ///
+ static int MaximumPathPosition => 22;
+
+ ///
+ /// Shows the state of the event.
+ /// When the event starts (), the client
+ /// shows the event frame and a message box with the failure conditions.
+ ///
+ /// The state.
+ ValueTask ShowStateAsync(DoppelgangerState state);
+
+ ///
+ /// Shows the position of the most advanced monster on the path.
+ ///
+ /// The position index.
+ ValueTask ShowMonsterPositionAsync(int position);
+
+ ///
+ /// Shows the ice walker on the path.
+ ///
+ /// The position index.
+ ValueTask ShowIceWalkerAsync(int position);
+
+ ///
+ /// Hides the ice walker, because it was killed or disappeared.
+ ///
+ ValueTask HideIceWalkerAsync();
+
+ ///
+ /// Shows the countdown of the last 30 seconds in which the ice walkers have to be killed.
+ ///
+ ValueTask ShowIceWalkerCountdownAsync();
+
+ ///
+ /// Shows the remaining time and the positions of the players on the path.
+ /// The client doesn't count down the time by itself, so this should be sent periodically.
+ /// Players which are not included are no longer shown.
+ ///
+ /// The remaining time.
+ /// The players with their position index.
+ ValueTask ShowPlayInfoAsync(TimeSpan remainingTime, IReadOnlyCollection<(Player Player, int Position)> playerPositions);
+
+ ///
+ /// Shows the number of monsters which reached the magic circle.
+ ///
+ /// The number of monsters which reached the magic circle.
+ /// The number of monsters which may reach the magic circle until the event fails.
+ ValueTask ShowMonsterGoalAsync(int goalCount, int maximumGoalCount);
+
+ ///
+ /// Shows the result of the event.
+ ///
+ /// The result.
+ ValueTask ShowResultAsync(DoppelgangerResult result);
+}
diff --git a/src/GameLogic/MiniGames/MiniGameManager.cs b/src/GameLogic/MiniGames/MiniGameManager.cs
index 4fc7b103aa..75e7a778e3 100644
--- a/src/GameLogic/MiniGames/MiniGameManager.cs
+++ b/src/GameLogic/MiniGames/MiniGameManager.cs
@@ -6,6 +6,7 @@ namespace MUnique.OpenMU.GameLogic.MiniGames;
using System.Collections.Concurrent;
using System.Diagnostics.Metrics;
+using MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
using MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
using Nito.AsyncEx;
@@ -80,6 +81,9 @@ public async ValueTask GetOrCreateAsync(MiniGameDefinition mini
case MiniGameType.BloodCastle:
miniGameContext = new BloodCastleContext(miniGameKey, miniGameDefinition, this._gameContext, this._mapInitializer);
break;
+ case MiniGameType.Doppelganger:
+ miniGameContext = new DoppelgangerContext(miniGameKey, miniGameDefinition, this._gameContext, this._mapInitializer);
+ break;
case MiniGameType.Kanturu:
miniGameContext = new KanturuContext(miniGameKey, miniGameDefinition, this._gameContext, this._mapInitializer);
break;
diff --git a/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs b/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs
new file mode 100644
index 0000000000..d99aad0ec2
--- /dev/null
+++ b/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs
@@ -0,0 +1,106 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.PlayerActions.MiniGames;
+
+using MUnique.OpenMU.GameLogic.MiniGames;
+using MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+using MUnique.OpenMU.GameLogic.Views.Inventory;
+
+///
+/// Action to enter the doppelganger event.
+///
+///
+/// The event takes place on one of several maps, each with its own .
+/// The first player of a party enters a randomly chosen one, and the other members of the party
+/// follow into the same instance.
+/// The event accepts two tickets, a Mirror of Dimensions and a Doppelganger Free Ticket, so the
+/// definitions don't define a ticket item. Instead, the ticket is checked and consumed here.
+///
+public class EnterDoppelgangerAction
+{
+ private const byte UndefinedTicketSlot = 0xFF;
+
+ private static readonly (byte Group, short Number)[] TicketItems =
+ [
+ (14, 111), // Mirror of Dimensions
+ (13, 125), // Doppelganger Free Ticket
+ ];
+
+ private readonly EnterMiniGameAction _enterAction = new();
+
+ ///
+ /// Tries to enter the doppelganger event.
+ ///
+ /// The player.
+ /// The inventory index of the ticket item, or 0xFF to search it in the inventory.
+ public async ValueTask TryEnterAsync(Player player, byte ticketInventoryIndex)
+ {
+ var definitions = player.GameContext.Configuration.MiniGameDefinitions
+ .Where(definition => definition.Type == MiniGameType.Doppelganger)
+ .ToList();
+ if (definitions.Count == 0 || FindTicket(player, ticketInventoryIndex) is not { } ticket)
+ {
+ await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(MiniGameType.Doppelganger, EnterResult.Failed)).ConfigureAwait(false);
+ return;
+ }
+
+ var definition = definitions.FirstOrDefault(d => player.GameContext.MiniGames.TryGetRunningMiniGame(d, player) is not null)
+ ?? definitions[Rand.NextInt(0, definitions.Count)];
+
+ await this._enterAction.TryEnterMiniGameAsync(player, MiniGameType.Doppelganger, definition.GameLevel, ticketInventoryIndex).ConfigureAwait(false);
+ if (player.CurrentMiniGame is DoppelgangerContext)
+ {
+ await ConsumeTicketAsync(player, ticket).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Finds the ticket which should be used to enter the event.
+ ///
+ /// The player.
+ /// The inventory index of the ticket item, or 0xFF to search it in the inventory.
+ /// The ticket, or null if the player has none.
+ internal static Item? FindTicket(Player player, byte ticketInventoryIndex)
+ {
+ if (player.Inventory is not { } inventory)
+ {
+ return null;
+ }
+
+ if (ticketInventoryIndex != UndefinedTicketSlot && inventory.GetItem(ticketInventoryIndex) is { } item && IsTicket(item))
+ {
+ return item;
+ }
+
+ // The Mirror of Dimensions is preferred, because the free ticket is usually bought.
+ return TicketItems
+ .SelectMany(ticket => inventory.Items.Where(i => i.Definition is { } definition && definition.Group == ticket.Group && definition.Number == ticket.Number))
+ .FirstOrDefault(IsTicket);
+ }
+
+ private static bool IsTicket(Item item)
+ {
+ return item.Durability > 0
+ && item.Definition is { } definition
+ && TicketItems.Any(ticket => ticket.Group == definition.Group && ticket.Number == definition.Number);
+ }
+
+ private static async ValueTask ConsumeTicketAsync(Player player, Item ticket)
+ {
+ ticket.Durability -= 1;
+ if (ticket.Durability > 0)
+ {
+ await player.InvokeViewPlugInAsync(p => p.ItemDurabilityChangedAsync(ticket, false)).ConfigureAwait(false);
+ return;
+ }
+
+ if (player.Inventory is { } inventory)
+ {
+ await inventory.RemoveItemAsync(ticket).ConfigureAwait(false);
+ }
+
+ await player.DestroyInventoryItemAsync(ticket).ConfigureAwait(false);
+ }
+}
diff --git a/src/GameLogic/PlugIns/DoppelgangerRewardChestPlugIn.cs b/src/GameLogic/PlugIns/DoppelgangerRewardChestPlugIn.cs
new file mode 100644
index 0000000000..2f5d77e569
--- /dev/null
+++ b/src/GameLogic/PlugIns/DoppelgangerRewardChestPlugIn.cs
@@ -0,0 +1,35 @@
+//
+// 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.MiniGames.Doppelganger;
+using MUnique.OpenMU.GameLogic.NPC;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Opens the reward chests of the doppelganger event, when a player talks to them.
+/// The client handles the chests as NPCs, so they're opened by clicking on them instead of attacking them.
+///
+[Guid("3E9A6C51-7B28-4F0D-A1C4-58D2E96B7F30")]
+[PlugIn]
+[Display(Name = nameof(PlugInResources.DoppelgangerRewardChestPlugIn_Name), Description = nameof(PlugInResources.DoppelgangerRewardChestPlugIn_Description), ResourceType = typeof(PlugInResources))]
+public class DoppelgangerRewardChestPlugIn : IPlayerTalkToNpcPlugIn
+{
+ ///
+ public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs)
+ {
+ if (player.CurrentMiniGame is not DoppelgangerContext context
+ || npc is not Destructible chest
+ || !context.IsRewardChest(chest))
+ {
+ return;
+ }
+
+ // Mark as handled before any await, so that the TalkNpcAction sees it synchronously.
+ eventArgs.HasBeenHandled = true;
+ await context.OpenRewardChestAsync(player, chest).ConfigureAwait(false);
+ }
+}
diff --git a/src/GameLogic/PlugIns/SignOfDimensionsStackedPlugIn.cs b/src/GameLogic/PlugIns/SignOfDimensionsStackedPlugIn.cs
new file mode 100644
index 0000000000..db1084f156
--- /dev/null
+++ b/src/GameLogic/PlugIns/SignOfDimensionsStackedPlugIn.cs
@@ -0,0 +1,44 @@
+//
+// 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.PlugIns;
+
+///
+/// This plugin transforms a full stack of signs of dimensions into a mirror of dimensions,
+/// which is the ticket of the doppelganger event.
+///
+[PlugIn]
+[Display(Name = nameof(PlugInResources.SignOfDimensionsStackedPlugIn_Name), Description = nameof(PlugInResources.SignOfDimensionsStackedPlugIn_Description), ResourceType = typeof(PlugInResources))]
+[Guid("6D2F8B41-0C7E-4A93-B15D-E84A3F29C706")]
+public sealed class SignOfDimensionsStackedPlugIn : IItemStackedPlugIn
+{
+ private const byte ItemGroup = 14;
+ private const short SignOfDimensionsNumber = 110;
+ private const short MirrorOfDimensionsNumber = 111;
+
+ ///
+ public async ValueTask ItemStackedAsync(Player player, Item sourceItem, Item targetItem)
+ {
+ if (targetItem.Definition is not { Group: ItemGroup, Number: SignOfDimensionsNumber } signOfDimensions
+ || targetItem.Durability < signOfDimensions.Durability)
+ {
+ return;
+ }
+
+ var mirrorOfDimensions = player.GameContext.Configuration.Items.FirstOrDefault(item => item is { Group: ItemGroup, Number: MirrorOfDimensionsNumber });
+ if (mirrorOfDimensions is null)
+ {
+ player.Logger.LogWarning("Mirror of dimensions definition not found.");
+ return;
+ }
+
+ await player.InvokeViewPlugInAsync(p => p.RemoveItemAsync(targetItem.ItemSlot)).ConfigureAwait(false);
+ targetItem.Definition = mirrorOfDimensions;
+ targetItem.Durability = 1;
+ await player.InvokeViewPlugInAsync(p => p.ItemAppearAsync(targetItem)).ConfigureAwait(false);
+ }
+}
diff --git a/src/GameLogic/Properties/PlayerMessage.Designer.cs b/src/GameLogic/Properties/PlayerMessage.Designer.cs
index a2794020b7..49588da0bd 100644
--- a/src/GameLogic/Properties/PlayerMessage.Designer.cs
+++ b/src/GameLogic/Properties/PlayerMessage.Designer.cs
@@ -177,6 +177,33 @@ public static string BloodCastleCrystalStatusDestroyed {
}
}
+ ///
+ /// Looks up a localized string similar to The Ice Walker appeared! Kill it within one minute, or the following monsters become stronger..
+ ///
+ public static string DoppelgangerIceWalkerAppeared {
+ get {
+ return ResourceManager.GetString("DoppelgangerIceWalkerAppeared", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The Ice Walker escaped! The following monsters are stronger..
+ ///
+ public static string DoppelgangerIceWalkerEscaped {
+ get {
+ return ResourceManager.GetString("DoppelgangerIceWalkerEscaped", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The Ice Walker has been defeated!.
+ ///
+ public static string DoppelgangerIceWalkerKilled {
+ get {
+ return ResourceManager.GetString("DoppelgangerIceWalkerKilled", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Your {0} has been destroyed! You are falling out of the Kanturu Refinery Tower..
///
diff --git a/src/GameLogic/Properties/PlayerMessage.resx b/src/GameLogic/Properties/PlayerMessage.resx
index 4d7119c152..d04c613c4e 100644
--- a/src/GameLogic/Properties/PlayerMessage.resx
+++ b/src/GameLogic/Properties/PlayerMessage.resx
@@ -792,4 +792,13 @@
You are not strong enough for this yet.
+
+ The Ice Walker appeared! Kill it within one minute, or the following monsters become stronger.
+
+
+ The Ice Walker escaped! The following monsters are stronger.
+
+
+ The Ice Walker has been defeated!
+
diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs
index 0c3f22ff66..02d82f39d8 100644
--- a/src/GameLogic/Properties/PlugInResources.Designer.cs
+++ b/src/GameLogic/Properties/PlugInResources.Designer.cs
@@ -609,6 +609,42 @@ public static string DisconnectChatCommandPlugIn_Name {
}
}
+ ///
+ /// Looks up a localized string similar to Configures the run of the doppelganger event, e.g. its monsters, their paths and multipliers..
+ ///
+ public static string DoppelgangerFeaturePlugIn_Description {
+ get {
+ return ResourceManager.GetString("DoppelgangerFeaturePlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Doppelganger event.
+ ///
+ public static string DoppelgangerFeaturePlugIn_Name {
+ get {
+ return ResourceManager.GetString("DoppelgangerFeaturePlugIn_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Opens the reward chests of the doppelganger event, when a player talks to them..
+ ///
+ public static string DoppelgangerRewardChestPlugIn_Description {
+ get {
+ return ResourceManager.GetString("DoppelgangerRewardChestPlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Doppelganger reward chests.
+ ///
+ public static string DoppelgangerRewardChestPlugIn_Name {
+ get {
+ return ResourceManager.GetString("DoppelgangerRewardChestPlugIn_Name", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Handles the dragon roar skill of the rage fighter class. Additionally to the attacked target, it will hit up to seven additional targets..
///
@@ -2699,6 +2735,24 @@ public static string SiegePotionConsumeHandlerPlugIn_Name {
}
}
+ ///
+ /// Looks up a localized string similar to Transforms a full stack of signs of dimensions into a mirror of dimensions..
+ ///
+ public static string SignOfDimensionsStackedPlugIn_Description {
+ get {
+ return ResourceManager.GetString("SignOfDimensionsStackedPlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Sign of dimensions stack transformation.
+ ///
+ public static string SignOfDimensionsStackedPlugIn_Name {
+ get {
+ return ResourceManager.GetString("SignOfDimensionsStackedPlugIn_Name", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Handles the chat command '/skin <number>'. Applies a monster skin to the game masters character..
///
diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx
index 7f4b93a4d0..a5b2ce76ae 100644
--- a/src/GameLogic/Properties/PlugInResources.resx
+++ b/src/GameLogic/Properties/PlugInResources.resx
@@ -1284,4 +1284,22 @@
Consumes Life Stones after they are successfully placed during Castle Siege.
+
+ Doppelganger reward chests
+
+
+ Opens the reward chests of the doppelganger event, when a player talks to them.
+
+
+ Sign of dimensions stack transformation
+
+
+ Transforms a full stack of signs of dimensions into a mirror of dimensions.
+
+
+ Doppelganger event
+
+
+ Configures the run of the doppelganger event, e.g. its monsters, their paths and multipliers.
+
diff --git a/src/GameServer/MessageHandler/MiniGames/DoppelgangerEnterRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/MiniGames/DoppelgangerEnterRequestHandlerPlugIn.cs
new file mode 100644
index 0000000000..e8cc346d17
--- /dev/null
+++ b/src/GameServer/MessageHandler/MiniGames/DoppelgangerEnterRequestHandlerPlugIn.cs
@@ -0,0 +1,52 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameServer.MessageHandler.MiniGames;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames;
+using MUnique.OpenMU.GameServer.MessageHandler.MuHelper;
+using MUnique.OpenMU.Network.Packets.ClientToServer;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Handler for the , which is sent by the
+/// entrance window of the NPC Lugard.
+///
+[PlugIn]
+[Display(Name = nameof(PlugInResources.DoppelgangerEnterRequestHandlerPlugIn_Name), Description = nameof(PlugInResources.DoppelgangerEnterRequestHandlerPlugIn_Description), ResourceType = typeof(PlugInResources))]
+[Guid("B21E6F93-4C7A-4D18-8E5B-9A30C2D7F615")]
+[BelongsToGroup(MuHelperGroupHandler.GroupKey)]
+internal class DoppelgangerEnterRequestHandlerPlugIn : ISubPacketHandlerPlugIn
+{
+ private readonly EnterDoppelgangerAction _enterAction = new();
+
+ ///
+ public bool IsEncryptionExpected => false;
+
+ ///
+ public byte Key => DoppelgangerEnterRequest.SubCode;
+
+ ///
+ public async ValueTask HandlePacketAsync(Player player, Memory packet)
+ {
+ if (packet.Length < DoppelgangerEnterRequest.Length
+ || player.SelectedCharacter?.CharacterClass is null
+ || player.OpenedNpc?.Definition.NpcWindow != NpcWindow.LugardDoppelgangerEntry)
+ {
+ return;
+ }
+
+ DoppelgangerEnterRequest request = packet;
+ await this._enterAction.TryEnterAsync(player, request.TicketItemSlot).ConfigureAwait(false);
+
+ if (player.CurrentMiniGame is not null)
+ {
+ // The client closes the window when it changes the map, so the player isn't at Lugard anymore.
+ player.OpenedNpc = null;
+ }
+ }
+}
diff --git a/src/GameServer/Properties/PlugInResources.Designer.cs b/src/GameServer/Properties/PlugInResources.Designer.cs
index 7e767d042f..c95bd29cfc 100644
--- a/src/GameServer/Properties/PlugInResources.Designer.cs
+++ b/src/GameServer/Properties/PlugInResources.Designer.cs
@@ -3192,6 +3192,42 @@ public static string KanturuEnterRequestHandlerPlugIn_Name {
}
}
+ ///
+ /// Looks up a localized string similar to Handler for the request to enter the doppelganger event through the NPC Lugard..
+ ///
+ public static string DoppelgangerEnterRequestHandlerPlugIn_Description {
+ get {
+ return ResourceManager.GetString("DoppelgangerEnterRequestHandlerPlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Doppelganger Enter Request Handler.
+ ///
+ public static string DoppelgangerEnterRequestHandlerPlugIn_Name {
+ get {
+ return ResourceManager.GetString("DoppelgangerEnterRequestHandlerPlugIn_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Sends the doppelganger event packets (0xBF 0x0F - 0x14) to the client..
+ ///
+ public static string DoppelgangerEventViewPlugIn_Description {
+ get {
+ return ResourceManager.GetString("DoppelgangerEventViewPlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Doppelganger Event View.
+ ///
+ public static string DoppelgangerEventViewPlugIn_Name {
+ get {
+ return ResourceManager.GetString("DoppelgangerEventViewPlugIn_Name", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Sends Kanturu event state/result/HUD packets (0xD1 group) to the client..
///
diff --git a/src/GameServer/Properties/PlugInResources.resx b/src/GameServer/Properties/PlugInResources.resx
index e9361de3f4..5b4e5b0a49 100644
--- a/src/GameServer/Properties/PlugInResources.resx
+++ b/src/GameServer/Properties/PlugInResources.resx
@@ -1335,6 +1335,18 @@
The default implementation of the IChaosCastleStateViewPlugin which is forwarding everything to the game client with specific data packets.
+
+ Doppelganger Enter Request Handler
+
+
+ Handler for the request to enter the doppelganger event through the NPC Lugard.
+
+
+ Doppelganger Event View
+
+
+ Sends the doppelganger event packets (0xBF 0x0F - 0x14) to the client.
+
Kanturu Event View
diff --git a/src/GameServer/RemoteView/MiniGames/DoppelgangerEventViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/DoppelgangerEventViewPlugIn.cs
new file mode 100644
index 0000000000..0542c840f2
--- /dev/null
+++ b/src/GameServer/RemoteView/MiniGames/DoppelgangerEventViewPlugIn.cs
@@ -0,0 +1,113 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameServer.RemoteView.MiniGames;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+using MUnique.OpenMU.GameLogic.Views;
+using MUnique.OpenMU.Network;
+using MUnique.OpenMU.Network.Packets.ServerToClient;
+using MUnique.OpenMU.PlugIns;
+using DoppelgangerResult = MUnique.OpenMU.GameLogic.MiniGames.Doppelganger.DoppelgangerResult;
+
+///
+/// The default implementation of the which
+/// sends the doppelganger event packets (0xBF 0x0F - 0x14) to the client.
+///
+[PlugIn]
+[Display(Name = nameof(PlugInResources.DoppelgangerEventViewPlugIn_Name), Description = nameof(PlugInResources.DoppelgangerEventViewPlugIn_Description), ResourceType = typeof(PlugInResources))]
+[Guid("5C0B7E2A-9D41-4F6B-A8E3-2F7C1D93B460")]
+public sealed class DoppelgangerEventViewPlugIn : IDoppelgangerEventViewPlugIn
+{
+ private readonly RemotePlayer _player;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The player.
+ public DoppelgangerEventViewPlugIn(RemotePlayer player) => this._player = player;
+
+ ///
+ public async ValueTask ShowStateAsync(DoppelgangerState state)
+ {
+ // The state of the packet has the same values as the state of the game logic.
+ await this._player.Connection.SendDoppelgangerStateUpdateAsync((DoppelgangerStateUpdate.DoppelgangerState)(byte)state).ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask ShowMonsterPositionAsync(int position)
+ {
+ await this._player.Connection.SendDoppelgangerMonsterPositionAsync(ToPositionByte(position)).ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask ShowIceWalkerAsync(int position)
+ {
+ await this._player.Connection.SendDoppelgangerIceWalkerStateAsync(DoppelgangerIceWalkerState.IceWalkerState.Appeared, ToPositionByte(position)).ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask HideIceWalkerAsync()
+ {
+ await this._player.Connection.SendDoppelgangerIceWalkerStateAsync(DoppelgangerIceWalkerState.IceWalkerState.Disappeared, 0).ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask ShowIceWalkerCountdownAsync()
+ {
+ await this._player.Connection.SendUpdateMiniGameStateAsync(UpdateMiniGameState.MiniGameTypeState.DoppelgangerIceWalkerCountdown).ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask ShowPlayInfoAsync(TimeSpan remainingTime, IReadOnlyCollection<(Player Player, int Position)> playerPositions)
+ {
+ if (this._player.Connection is not { Connected: true } connection)
+ {
+ return;
+ }
+
+ int WritePacket()
+ {
+ var count = Math.Min(playerPositions.Count, byte.MaxValue);
+ var length = DoppelgangerPlayInfoRef.GetRequiredSize(count);
+ var packet = new DoppelgangerPlayInfoRef(connection.Output.GetSpan(length)[..length]);
+ packet.RemainingSeconds = (ushort)Math.Clamp(remainingTime.TotalSeconds, 0, ushort.MaxValue);
+ packet.PlayerCount = (byte)count;
+
+ var i = 0;
+ foreach (var (player, position) in playerPositions.Take(count))
+ {
+ var playerPosition = packet[i];
+ playerPosition.PlayerId = player.GetId(this._player);
+ playerPosition.MapNumber = (byte)(player.CurrentMap?.MapId ?? 0);
+ playerPosition.Position = ToPositionByte(position);
+ i++;
+ }
+
+ return length;
+ }
+
+ await connection.SendAsync(WritePacket).ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask ShowMonsterGoalAsync(int goalCount, int maximumGoalCount)
+ {
+ await this._player.Connection.SendDoppelgangerMonsterGoalAsync(
+ (byte)Math.Clamp(maximumGoalCount, 0, byte.MaxValue),
+ (byte)Math.Clamp(goalCount, 0, byte.MaxValue))
+ .ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask ShowResultAsync(DoppelgangerResult result)
+ {
+ // The client doesn't show the reward experience, so it's always sent as 0.
+ await this._player.Connection.SendDoppelgangerResultAsync((Network.Packets.ServerToClient.DoppelgangerResult.ResultType)(byte)result, 0).ConfigureAwait(false);
+ }
+
+ private static byte ToPositionByte(int position) => (byte)Math.Clamp(position, 0, IDoppelgangerEventViewPlugIn.MaximumPathPosition);
+}
diff --git a/src/GameServer/RemoteView/MiniGames/Extensions.cs b/src/GameServer/RemoteView/MiniGames/Extensions.cs
index 9ce81ee8ab..684ff9ae41 100644
--- a/src/GameServer/RemoteView/MiniGames/Extensions.cs
+++ b/src/GameServer/RemoteView/MiniGames/Extensions.cs
@@ -70,4 +70,21 @@ public static ChaosCastleEnterResult.EnterResult ToChaosCastleEnterResult(this E
_ => ChaosCastleEnterResult.EnterResult.Failed,
};
}
-}
\ No newline at end of file
+
+ ///
+ /// Converts the to the corresponding .
+ ///
+ /// The enter result.
+ /// The converted result.
+ public static DoppelgangerEnterResult.EnterResult ToDoppelgangerEnterResult(this EnterResult enterResult)
+ {
+ return enterResult switch
+ {
+ EnterResult.Success => DoppelgangerEnterResult.EnterResult.Success,
+ EnterResult.NotOpen => DoppelgangerEnterResult.EnterResult.AlreadyStarted,
+ EnterResult.Full => DoppelgangerEnterResult.EnterResult.AlreadyStarted,
+ EnterResult.PlayerKillerCantEnter => DoppelgangerEnterResult.EnterResult.PlayerKiller,
+ _ => DoppelgangerEnterResult.EnterResult.Failed,
+ };
+ }
+}
diff --git a/src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs
index 702a539090..013f86cecc 100644
--- a/src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs
+++ b/src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs
@@ -41,6 +41,9 @@ public async ValueTask ShowResultAsync(MiniGameType miniGameType, EnterResult en
case MiniGameType.ChaosCastle:
await this._player.Connection.SendChaosCastleEnterResultAsync(enterResult.ToChaosCastleEnterResult()).ConfigureAwait(false);
break;
+ case MiniGameType.Doppelganger:
+ await this._player.Connection.SendDoppelgangerEnterResultAsync(enterResult.ToDoppelgangerEnterResult()).ConfigureAwait(false);
+ break;
case MiniGameType.Undefined:
throw new ArgumentException("undefined game type", nameof(miniGameType));
default:
diff --git a/src/GameServer/RemoteView/MiniGames/UpdateMiniGameStateViewViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/UpdateMiniGameStateViewViewPlugIn.cs
index 1436e26720..66a6a0e45d 100644
--- a/src/GameServer/RemoteView/MiniGames/UpdateMiniGameStateViewViewPlugIn.cs
+++ b/src/GameServer/RemoteView/MiniGames/UpdateMiniGameStateViewViewPlugIn.cs
@@ -72,6 +72,12 @@ private static bool TryGetState(MiniGameType type, MiniGameState state, out Upda
case (MiniGameType.ChaosCastle, MiniGameState.Ended):
miniGameState = UpdateMiniGameState.MiniGameTypeState.ChaosCastleFinished;
break;
+ case (MiniGameType.Doppelganger, MiniGameState.Closed):
+ miniGameState = UpdateMiniGameState.MiniGameTypeState.DoppelgangerStarting;
+ break;
+ case (MiniGameType.Doppelganger, MiniGameState.Ended):
+ miniGameState = UpdateMiniGameState.MiniGameTypeState.DoppelgangerEnding;
+ break;
default:
miniGameState = default;
return false;
diff --git a/src/GameServer/RemoteView/NPC/OpenNpcWindowPlugIn.cs b/src/GameServer/RemoteView/NPC/OpenNpcWindowPlugIn.cs
index 2dbb1ad471..30b99258ef 100644
--- a/src/GameServer/RemoteView/NPC/OpenNpcWindowPlugIn.cs
+++ b/src/GameServer/RemoteView/NPC/OpenNpcWindowPlugIn.cs
@@ -37,6 +37,10 @@ public async ValueTask OpenNpcWindowAsync(NpcWindow window)
await this._player.Connection.SendOpenNpcDialogAsync(this._player.OpenedNpc.Definition.Number.ToUnsigned(), 0).ConfigureAwait(false);
}
}
+ else if (window == NpcWindow.LugardDoppelgangerEntry)
+ {
+ await this.OpenDoppelgangerEntryWindowAsync().ConfigureAwait(false);
+ }
else
{
await this._player.Connection.SendNpcWindowResponseAsync(Convert(window)).ConfigureAwait(false);
@@ -77,4 +81,32 @@ private static NpcWindowResponse.NpcWindow Convert(NpcWindow window)
_ => throw new ArgumentException($"Unhandled case {window}."),
};
}
-}
\ No newline at end of file
+
+ ///
+ /// Opens the entrance window of the doppelganger event.
+ /// The client reads the minutes until the entrance opens from the byte after the window.
+ /// The event can always be entered, so it's 0 ("You may now enter.").
+ ///
+ private async ValueTask OpenDoppelgangerEntryWindowAsync()
+ {
+ if (this._player.Connection is not { Connected: true } connection)
+ {
+ return;
+ }
+
+ int WritePacket()
+ {
+ var length = NpcWindowResponseRef.Length;
+ var span = connection.Output.GetSpan(length)[..length];
+ span.Clear();
+ _ = new NpcWindowResponseRef(span)
+ {
+ Window = NpcWindowResponse.NpcWindow.LugardDoppelgangerEntry,
+ };
+
+ return length;
+ }
+
+ await connection.SendAsync(WritePacket).ConfigureAwait(false);
+ }
+}
diff --git a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs
index 7d50718b01..43deceab9b 100644
--- a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs
+++ b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs
@@ -5502,6 +5502,180 @@ int WritePacket()
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
+ ///
+ /// Sends a to this connection.
+ ///
+ /// The connection.
+ /// The result.
+ ///
+ /// 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.
+ ///
+ public static async ValueTask SendDoppelgangerEnterResultAsync(this IConnection? connection, DoppelgangerEnterResult.EnterResult @result)
+ {
+ if (connection is null)
+ {
+ return;
+ }
+
+ int WritePacket()
+ {
+ var length = DoppelgangerEnterResultRef.Length;
+ var packet = new DoppelgangerEnterResultRef(connection.Output.GetSpan(length)[..length]);
+ packet.Result = @result;
+
+ return packet.Header.Length;
+ }
+
+ await connection.SendAsync(WritePacket).ConfigureAwait(false);
+ }
+
+ ///
+ /// Sends a to this connection.
+ ///
+ /// The connection.
+ /// The position index on the path, between 0 (start) and 22 (magic circle).
+ ///
+ /// Is sent by the server when: The position of the most advanced monster on the path to the magic circle changed during the doppelganger event.
+ /// Causes reaction on client side: The client updates the monster progress bar of the doppelganger frame.
+ ///
+ public static async ValueTask SendDoppelgangerMonsterPositionAsync(this IConnection? connection, byte @position)
+ {
+ if (connection is null)
+ {
+ return;
+ }
+
+ int WritePacket()
+ {
+ var length = DoppelgangerMonsterPositionRef.Length;
+ var packet = new DoppelgangerMonsterPositionRef(connection.Output.GetSpan(length)[..length]);
+ packet.Position = @position;
+
+ return packet.Header.Length;
+ }
+
+ await connection.SendAsync(WritePacket).ConfigureAwait(false);
+ }
+
+ ///
+ /// Sends a to this connection.
+ ///
+ /// The connection.
+ /// The state.
+ ///
+ /// Is sent by the server when: The state of the doppelganger event changed.
+ /// Causes reaction on client side: When the event starts (state Playing), the client shows the doppelganger frame and a message box with the failure conditions.
+ ///
+ public static async ValueTask SendDoppelgangerStateUpdateAsync(this IConnection? connection, DoppelgangerStateUpdate.DoppelgangerState @state)
+ {
+ if (connection is null)
+ {
+ return;
+ }
+
+ int WritePacket()
+ {
+ var length = DoppelgangerStateUpdateRef.Length;
+ var packet = new DoppelgangerStateUpdateRef(connection.Output.GetSpan(length)[..length]);
+ packet.State = @state;
+
+ return packet.Header.Length;
+ }
+
+ await connection.SendAsync(WritePacket).ConfigureAwait(false);
+ }
+
+ ///
+ /// Sends a to this connection.
+ ///
+ /// The connection.
+ /// The state.
+ /// The position index of the ice walker on the path, between 0 (start) and 22 (magic circle).
+ ///
+ /// Is sent by the server when: The ice walker appeared on or disappeared from the path during the doppelganger event.
+ /// Causes reaction on client side: The client shows or hides the ice walker icon on the progress bar of the doppelganger frame.
+ ///
+ public static async ValueTask SendDoppelgangerIceWalkerStateAsync(this IConnection? connection, DoppelgangerIceWalkerState.IceWalkerState @state, byte @position)
+ {
+ if (connection is null)
+ {
+ return;
+ }
+
+ int WritePacket()
+ {
+ var length = DoppelgangerIceWalkerStateRef.Length;
+ var packet = new DoppelgangerIceWalkerStateRef(connection.Output.GetSpan(length)[..length]);
+ packet.State = @state;
+ packet.Position = @position;
+
+ return packet.Header.Length;
+ }
+
+ await connection.SendAsync(WritePacket).ConfigureAwait(false);
+ }
+
+ ///
+ /// Sends a to this connection.
+ ///
+ /// The connection.
+ /// The result.
+ /// The experience which the player got as reward. It is not shown by the client. The field is aligned to 4 bytes, because the client structure is not packed.
+ ///
+ /// Is sent by the server when: The doppelganger event ended for the player.
+ /// Causes reaction on client side: The client stops the timer and the event music, and shows a message box with the result.
+ ///
+ public static async ValueTask SendDoppelgangerResultAsync(this IConnection? connection, DoppelgangerResult.ResultType @result, uint @rewardExperience)
+ {
+ if (connection is null)
+ {
+ return;
+ }
+
+ int WritePacket()
+ {
+ var length = DoppelgangerResultRef.Length;
+ var packet = new DoppelgangerResultRef(connection.Output.GetSpan(length)[..length]);
+ packet.Result = @result;
+ packet.RewardExperience = @rewardExperience;
+
+ return packet.Header.Length;
+ }
+
+ await connection.SendAsync(WritePacket).ConfigureAwait(false);
+ }
+
+ ///
+ /// Sends a to this connection.
+ ///
+ /// The connection.
+ /// The number of monsters which may reach the magic circle until the event fails.
+ /// The number of monsters which reached the magic circle.
+ ///
+ /// Is sent by the server when: A monster reached the magic circle during the doppelganger event.
+ /// Causes reaction on client side: The client updates the counter of monsters which passed the magic circle.
+ ///
+ public static async ValueTask SendDoppelgangerMonsterGoalAsync(this IConnection? connection, byte @maximumGoalCount, byte @goalCount)
+ {
+ if (connection is null)
+ {
+ return;
+ }
+
+ int WritePacket()
+ {
+ var length = DoppelgangerMonsterGoalRef.Length;
+ var packet = new DoppelgangerMonsterGoalRef(connection.Output.GetSpan(length)[..length]);
+ packet.MaximumGoalCount = @maximumGoalCount;
+ packet.GoalCount = @goalCount;
+
+ 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 7f6fee195c..9436b1ad1e 100644
--- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs
+++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs
@@ -26805,6 +26805,795 @@ public ushort TargetId
}
+///
+/// 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.
+///
+public readonly struct DoppelgangerEnterResult
+{
+ ///
+ /// Result of the doppelganger enter request.
+ ///
+ public enum EnterResult
+ {
+ ///
+ /// The player entered the event.
+ ///
+ Success = 0,
+
+ ///
+ /// The player could not enter, e.g. because of a missing ticket item. The client locks the enter button without showing a message.
+ ///
+ Failed = 1,
+
+ ///
+ /// The event is already running or occupied by another party. The client shows "Battle has already commenced. You cannot enter.".
+ ///
+ AlreadyStarted = 2,
+
+ ///
+ /// Player killers are not allowed to enter. The client shows "You cannot enter if you are a 1st Stage Outlaw.".
+ ///
+ PlayerKiller = 3,
+
+ ///
+ /// The client unlocks the enter button of the doppelganger entry dialog.
+ ///
+ EntranceAvailable = 4,
+ }
+
+ private readonly Memory _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerEnterResult(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 DoppelgangerEnterResult(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 => 0x0E;
+
+ ///
+ /// 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 DoppelgangerEnterResult.EnterResult Result
+ {
+ get => (EnterResult)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 DoppelgangerEnterResult(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(DoppelgangerEnterResult packet) => packet._data;
+}
+
+
+///
+/// Is sent by the server when: The position of the most advanced monster on the path to the magic circle changed during the doppelganger event.
+/// Causes reaction on client side: The client updates the monster progress bar of the doppelganger frame.
+///
+public readonly struct DoppelgangerMonsterPosition
+{
+ private readonly Memory _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerMonsterPosition(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 DoppelgangerMonsterPosition(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 => 0x0F;
+
+ ///
+ /// 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 position index on the path, between 0 (start) and 22 (magic circle).
+ ///
+ public byte Position
+ {
+ get => this._data.Span[4];
+ set => 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 DoppelgangerMonsterPosition(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(DoppelgangerMonsterPosition packet) => packet._data;
+}
+
+
+///
+/// Is sent by the server when: The state of the doppelganger event changed.
+/// Causes reaction on client side: When the event starts (state Playing), the client shows the doppelganger frame and a message box with the failure conditions.
+///
+public readonly struct DoppelgangerStateUpdate
+{
+ ///
+ /// The state of the doppelganger event.
+ ///
+ public enum DoppelgangerState
+ {
+ ///
+ /// The event is waiting for the party members to enter.
+ ///
+ Waiting = 0,
+
+ ///
+ /// The entrance is closed and the event is about to start.
+ ///
+ Ready = 1,
+
+ ///
+ /// The event is running.
+ ///
+ Playing = 2,
+
+ ///
+ /// The event has ended.
+ ///
+ Ended = 3,
+ }
+
+ private readonly Memory _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerStateUpdate(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 DoppelgangerStateUpdate(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 => 0x10;
+
+ ///
+ /// 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 state.
+ ///
+ public DoppelgangerStateUpdate.DoppelgangerState State
+ {
+ get => (DoppelgangerState)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 DoppelgangerStateUpdate(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(DoppelgangerStateUpdate packet) => packet._data;
+}
+
+
+///
+/// Is sent by the server when: The ice walker appeared on or disappeared from the path during the doppelganger event.
+/// Causes reaction on client side: The client shows or hides the ice walker icon on the progress bar of the doppelganger frame.
+///
+public readonly struct DoppelgangerIceWalkerState
+{
+ ///
+ /// The state of the ice walker.
+ ///
+ public enum IceWalkerState
+ {
+ ///
+ /// The ice walker is present at the given position.
+ ///
+ Appeared = 0,
+
+ ///
+ /// The ice walker was killed or disappeared.
+ ///
+ Disappeared = 1,
+ }
+
+ private readonly Memory _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerIceWalkerState(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 DoppelgangerIceWalkerState(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 => 0x11;
+
+ ///
+ /// 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 => 6;
+
+ ///
+ /// Gets the header of this packet.
+ ///
+ public C1HeaderWithSubCode Header => new (this._data);
+
+ ///
+ /// Gets or sets the state.
+ ///
+ public DoppelgangerIceWalkerState.IceWalkerState State
+ {
+ get => (IceWalkerState)this._data.Span[4];
+ set => this._data.Span[4] = (byte)value;
+ }
+
+ ///
+ /// Gets or sets the position index of the ice walker on the path, between 0 (start) and 22 (magic circle).
+ ///
+ public byte Position
+ {
+ get => this._data.Span[5];
+ set => this._data.Span[5] = value;
+ }
+
+ ///
+ /// Performs an implicit conversion from a Memory of bytes to a .
+ ///
+ /// The packet as span.
+ /// The packet as struct.
+ public static implicit operator DoppelgangerIceWalkerState(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(DoppelgangerIceWalkerState packet) => packet._data;
+}
+
+
+///
+/// Is sent by the server when: Periodically (every second) while the doppelganger event is running.
+/// Causes reaction on client side: The client updates the remaining time and the positions of the party members on the progress bar of the doppelganger frame. Players which are not included are no longer shown.
+///
+public readonly struct DoppelgangerPlayInfo
+{
+ private readonly Memory _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerPlayInfo(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 DoppelgangerPlayInfo(Memory data, bool initialize)
+ {
+ this._data = data;
+ if (initialize)
+ {
+ var header = this.Header;
+ header.Type = HeaderType;
+ header.Code = Code;
+ header.Length = (byte)data.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 => 0x12;
+
+ ///
+ /// Gets the header of this packet.
+ ///
+ public C1HeaderWithSubCode Header => new (this._data);
+
+ ///
+ /// Gets or sets the remaining seconds.
+ ///
+ public ushort RemainingSeconds
+ {
+ get => ReadUInt16LittleEndian(this._data.Span[4..]);
+ set => WriteUInt16LittleEndian(this._data.Span[4..], value);
+ }
+
+ ///
+ /// Gets or sets the player count.
+ ///
+ public byte PlayerCount
+ {
+ get => this._data.Span[6];
+ set => this._data.Span[6] = value;
+ }
+
+ ///
+ /// Gets the of the specified index.
+ ///
+ public PlayerPosition this[int index] => new (this._data.Slice(8 + index * PlayerPosition.Length));
+
+ ///
+ /// Performs an implicit conversion from a Memory of bytes to a .
+ ///
+ /// The packet as span.
+ /// The packet as struct.
+ public static implicit operator DoppelgangerPlayInfo(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(DoppelgangerPlayInfo packet) => packet._data;
+
+ ///
+ /// Calculates the size of the packet for the specified count of .
+ ///
+ /// The count of from which the size will be calculated.
+
+ public static int GetRequiredSize(int playerPositionsCount) => playerPositionsCount * PlayerPosition.Length + 8;
+
+
+///
+/// The position of a player on the path..
+///
+public readonly struct PlayerPosition
+{
+ private readonly Memory _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public PlayerPosition(Memory data)
+ {
+ this._data = data;
+ }
+
+ ///
+ /// 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 => 4;
+
+ ///
+ /// Gets or sets the player id.
+ ///
+ public ushort PlayerId
+ {
+ get => ReadUInt16LittleEndian(this._data.Span);
+ set => WriteUInt16LittleEndian(this._data.Span, value);
+ }
+
+ ///
+ /// Gets or sets the map number of the player. It is ignored by the client.
+ ///
+ public byte MapNumber
+ {
+ get => this._data.Span[2];
+ set => this._data.Span[2] = value;
+ }
+
+ ///
+ /// Gets or sets the position index of the player on the path, between 0 (start) and 22 (magic circle).
+ ///
+ public byte Position
+ {
+ get => this._data.Span[3];
+ set => this._data.Span[3] = value;
+ }
+}
+}
+
+
+///
+/// Is sent by the server when: The doppelganger event ended for the player.
+/// Causes reaction on client side: The client stops the timer and the event music, and shows a message box with the result.
+///
+public readonly struct DoppelgangerResult
+{
+ ///
+ /// The result of the doppelganger event.
+ ///
+ public enum ResultType
+ {
+ ///
+ /// The party successfully defended the magic circle.
+ ///
+ Success = 0,
+
+ ///
+ /// The player failed, e.g. because the character died or left the event map.
+ ///
+ Failed = 1,
+
+ ///
+ /// The defense failed, because too many monsters reached the magic circle.
+ ///
+ MonstersReachedMagicCircle = 2,
+ }
+
+ private readonly Memory _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerResult(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 DoppelgangerResult(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 => 0x13;
+
+ ///
+ /// 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 DoppelgangerResult.ResultType Result
+ {
+ get => (ResultType)this._data.Span[4];
+ set => this._data.Span[4] = (byte)value;
+ }
+
+ ///
+ /// Gets or sets the experience which the player got as reward. It is not shown by the client. The field is aligned to 4 bytes, because the client structure is not packed.
+ ///
+ public uint RewardExperience
+ {
+ 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 DoppelgangerResult(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(DoppelgangerResult packet) => packet._data;
+}
+
+
+///
+/// Is sent by the server when: A monster reached the magic circle during the doppelganger event.
+/// Causes reaction on client side: The client updates the counter of monsters which passed the magic circle.
+///
+public readonly struct DoppelgangerMonsterGoal
+{
+ private readonly Memory _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerMonsterGoal(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 DoppelgangerMonsterGoal(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 => 0x14;
+
+ ///
+ /// 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 => 6;
+
+ ///
+ /// Gets the header of this packet.
+ ///
+ public C1HeaderWithSubCode Header => new (this._data);
+
+ ///
+ /// Gets or sets the number of monsters which may reach the magic circle until the event fails.
+ ///
+ public byte MaximumGoalCount
+ {
+ get => this._data.Span[4];
+ set => this._data.Span[4] = value;
+ }
+
+ ///
+ /// Gets or sets the number of monsters which reached the magic circle.
+ ///
+ public byte GoalCount
+ {
+ get => this._data.Span[5];
+ set => this._data.Span[5] = value;
+ }
+
+ ///
+ /// Performs an implicit conversion from a Memory of bytes to a .
+ ///
+ /// The packet as span.
+ /// The packet as struct.
+ public static implicit operator DoppelgangerMonsterGoal(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(DoppelgangerMonsterGoal packet) => packet._data;
+}
+
+
///
/// Is sent by the server when: The server validated or changed the status of the MU Helper.
/// Causes reaction on client side: The client toggle the MU Helper status.
@@ -29798,6 +30587,26 @@ public enum MiniGameTypeState
/// The chaos castle game is finished. Chaos Castle Event shuts down (in x seconds)".
///
ChaosCastleFinished = 13,
+
+ ///
+ /// The entrance of the doppelganger event closes in 30 seconds. The client shows the countdown only on the doppelganger maps.
+ ///
+ DoppelgangerEntranceClosing = 16,
+
+ ///
+ /// The doppelganger event starts in 30 seconds. The client shows the countdown only on the doppelganger maps.
+ ///
+ DoppelgangerStarting = 17,
+
+ ///
+ /// The ice walker has to be killed within 30 seconds. The client shows the countdown only on the doppelganger maps.
+ ///
+ DoppelgangerIceWalkerCountdown = 18,
+
+ ///
+ /// The doppelganger event ends in 30 seconds. The client shows the countdown only on the doppelganger maps.
+ ///
+ DoppelgangerEnding = 19,
}
private readonly Memory _data;
diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml
index a2ddfb9570..4ccbd901ce 100644
--- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml
+++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml
@@ -9705,6 +9705,288 @@
+
+
+ C1HeaderWithSubCode
+ BF
+ 0E
+ DoppelgangerEnterResult
+ 5
+ ServerToClient
+ The player requested to enter the doppelganger event through the NPC Lugard.
+ On failure, the client locks the enter button of the doppelganger entry dialog and may show a message.
+
+
+ 4
+ Enum
+ EnterResult
+ Result
+
+
+
+
+ EnterResult
+ Result of the doppelganger enter request.
+
+
+ Success
+ The player entered the event.
+ 0
+
+
+ Failed
+ The player could not enter, e.g. because of a missing ticket item. The client locks the enter button without showing a message.
+ 1
+
+
+ AlreadyStarted
+ The event is already running or occupied by another party. The client shows "Battle has already commenced. You cannot enter.".
+ 2
+
+
+ PlayerKiller
+ Player killers are not allowed to enter. The client shows "You cannot enter if you are a 1st Stage Outlaw.".
+ 3
+
+
+ EntranceAvailable
+ The client unlocks the enter button of the doppelganger entry dialog.
+ 4
+
+
+
+
+
+
+ C1HeaderWithSubCode
+ BF
+ 0F
+ DoppelgangerMonsterPosition
+ 5
+ ServerToClient
+ The position of the most advanced monster on the path to the magic circle changed during the doppelganger event.
+ The client updates the monster progress bar of the doppelganger frame.
+
+
+ 4
+ Byte
+ Position
+ The position index on the path, between 0 (start) and 22 (magic circle).
+
+
+
+
+ C1HeaderWithSubCode
+ BF
+ 10
+ DoppelgangerStateUpdate
+ 5
+ ServerToClient
+ The state of the doppelganger event changed.
+ When the event starts (state Playing), the client shows the doppelganger frame and a message box with the failure conditions.
+
+
+ 4
+ Enum
+ DoppelgangerState
+ State
+
+
+
+
+ DoppelgangerState
+ The state of the doppelganger event.
+
+
+ Waiting
+ The event is waiting for the party members to enter.
+ 0
+
+
+ Ready
+ The entrance is closed and the event is about to start.
+ 1
+
+
+ Playing
+ The event is running.
+ 2
+
+
+ Ended
+ The event has ended.
+ 3
+
+
+
+
+
+
+ C1HeaderWithSubCode
+ BF
+ 11
+ DoppelgangerIceWalkerState
+ 6
+ ServerToClient
+ The ice walker appeared on or disappeared from the path during the doppelganger event.
+ The client shows or hides the ice walker icon on the progress bar of the doppelganger frame.
+
+
+ 4
+ Enum
+ IceWalkerState
+ State
+
+
+ 5
+ Byte
+ Position
+ The position index of the ice walker on the path, between 0 (start) and 22 (magic circle).
+
+
+
+
+ IceWalkerState
+ The state of the ice walker.
+
+
+ Appeared
+ The ice walker is present at the given position.
+ 0
+
+
+ Disappeared
+ The ice walker was killed or disappeared.
+ 1
+
+
+
+
+
+
+ C1HeaderWithSubCode
+ BF
+ 12
+ DoppelgangerPlayInfo
+ ServerToClient
+ Periodically (every second) while the doppelganger event is running.
+ The client updates the remaining time and the positions of the party members on the progress bar of the doppelganger frame. Players which are not included are no longer shown.
+
+
+ 4
+ ShortLittleEndian
+ RemainingSeconds
+
+
+ 6
+ Byte
+ PlayerCount
+
+
+ 8
+ Structure[]
+ PlayerPosition
+ PlayerPositions
+ PlayerCount
+
+
+
+
+ PlayerPosition
+ The position of a player on the path.
+ 4
+
+
+ 0
+ ShortLittleEndian
+ PlayerId
+
+
+ 2
+ Byte
+ MapNumber
+ The map number of the player. It is ignored by the client.
+
+
+ 3
+ Byte
+ Position
+ The position index of the player on the path, between 0 (start) and 22 (magic circle).
+
+
+
+
+
+
+ C1HeaderWithSubCode
+ BF
+ 13
+ DoppelgangerResult
+ 12
+ ServerToClient
+ The doppelganger event ended for the player.
+ The client stops the timer and the event music, and shows a message box with the result.
+
+
+ 4
+ Enum
+ ResultType
+ Result
+
+
+ 8
+ IntegerLittleEndian
+ RewardExperience
+ The experience which the player got as reward. It is not shown by the client. The field is aligned to 4 bytes, because the client structure is not packed.
+
+
+
+
+ ResultType
+ The result of the doppelganger event.
+
+
+ Success
+ The party successfully defended the magic circle.
+ 0
+
+
+ Failed
+ The player failed, e.g. because the character died or left the event map.
+ 1
+
+
+ MonstersReachedMagicCircle
+ The defense failed, because too many monsters reached the magic circle.
+ 2
+
+
+
+
+
+
+ C1HeaderWithSubCode
+ BF
+ 14
+ DoppelgangerMonsterGoal
+ 6
+ ServerToClient
+ A monster reached the magic circle during the doppelganger event.
+ The client updates the counter of monsters which passed the magic circle.
+
+
+ 4
+ Byte
+ MaximumGoalCount
+ The number of monsters which may reach the magic circle until the event fails.
+
+
+ 5
+ Byte
+ GoalCount
+ The number of monsters which reached the magic circle.
+
+
C1HeaderWithSubCode
@@ -10712,6 +10994,26 @@
The chaos castle game is finished. Chaos Castle Event shuts down (in x seconds)".
13
+
+ DoppelgangerEntranceClosing
+ The entrance of the doppelganger event closes in 30 seconds. The client shows the countdown only on the doppelganger maps.
+ 16
+
+
+ DoppelgangerStarting
+ The doppelganger event starts in 30 seconds. The client shows the countdown only on the doppelganger maps.
+ 17
+
+
+ DoppelgangerIceWalkerCountdown
+ The ice walker has to be killed within 30 seconds. The client shows the countdown only on the doppelganger maps.
+ 18
+
+
+ DoppelgangerEnding
+ The doppelganger event ends in 30 seconds. The client shows the countdown only on the doppelganger maps.
+ 19
+
diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs
index fff09c959b..63519dcdd3 100644
--- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs
+++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs
@@ -25471,6 +25471,701 @@ public ushort TargetId
}
+///
+/// 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.
+///
+public readonly ref struct DoppelgangerEnterResultRef
+{
+ private readonly Span _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerEnterResultRef(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 DoppelgangerEnterResultRef(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 => 0x0E;
+
+ ///
+ /// 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 DoppelgangerEnterResult.EnterResult Result
+ {
+ get => (DoppelgangerEnterResult.EnterResult)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 DoppelgangerEnterResultRef(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(DoppelgangerEnterResultRef packet) => packet._data;
+}
+
+
+///
+/// Is sent by the server when: The position of the most advanced monster on the path to the magic circle changed during the doppelganger event.
+/// Causes reaction on client side: The client updates the monster progress bar of the doppelganger frame.
+///
+public readonly ref struct DoppelgangerMonsterPositionRef
+{
+ private readonly Span _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerMonsterPositionRef(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 DoppelgangerMonsterPositionRef(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 => 0x0F;
+
+ ///
+ /// 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 position index on the path, between 0 (start) and 22 (magic circle).
+ ///
+ public byte Position
+ {
+ get => this._data[4];
+ set => 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 DoppelgangerMonsterPositionRef(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(DoppelgangerMonsterPositionRef packet) => packet._data;
+}
+
+
+///
+/// Is sent by the server when: The state of the doppelganger event changed.
+/// Causes reaction on client side: When the event starts (state Playing), the client shows the doppelganger frame and a message box with the failure conditions.
+///
+public readonly ref struct DoppelgangerStateUpdateRef
+{
+ private readonly Span _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerStateUpdateRef(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 DoppelgangerStateUpdateRef(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 => 0x10;
+
+ ///
+ /// 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 state.
+ ///
+ public DoppelgangerStateUpdate.DoppelgangerState State
+ {
+ get => (DoppelgangerStateUpdate.DoppelgangerState)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 DoppelgangerStateUpdateRef(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(DoppelgangerStateUpdateRef packet) => packet._data;
+}
+
+
+///
+/// Is sent by the server when: The ice walker appeared on or disappeared from the path during the doppelganger event.
+/// Causes reaction on client side: The client shows or hides the ice walker icon on the progress bar of the doppelganger frame.
+///
+public readonly ref struct DoppelgangerIceWalkerStateRef
+{
+ private readonly Span _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerIceWalkerStateRef(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 DoppelgangerIceWalkerStateRef(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 => 0x11;
+
+ ///
+ /// 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 => 6;
+
+ ///
+ /// Gets the header of this packet.
+ ///
+ public C1HeaderWithSubCodeRef Header => new (this._data);
+
+ ///
+ /// Gets or sets the state.
+ ///
+ public DoppelgangerIceWalkerState.IceWalkerState State
+ {
+ get => (DoppelgangerIceWalkerState.IceWalkerState)this._data[4];
+ set => this._data[4] = (byte)value;
+ }
+
+ ///
+ /// Gets or sets the position index of the ice walker on the path, between 0 (start) and 22 (magic circle).
+ ///
+ public byte Position
+ {
+ get => this._data[5];
+ set => this._data[5] = value;
+ }
+
+ ///
+ /// Performs an implicit conversion from a Span of bytes to a .
+ ///
+ /// The packet as span.
+ /// The packet as struct.
+ public static implicit operator DoppelgangerIceWalkerStateRef(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(DoppelgangerIceWalkerStateRef packet) => packet._data;
+}
+
+
+///
+/// Is sent by the server when: Periodically (every second) while the doppelganger event is running.
+/// Causes reaction on client side: The client updates the remaining time and the positions of the party members on the progress bar of the doppelganger frame. Players which are not included are no longer shown.
+///
+public readonly ref struct DoppelgangerPlayInfoRef
+{
+ private readonly Span _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerPlayInfoRef(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 DoppelgangerPlayInfoRef(Span data, bool initialize)
+ {
+ this._data = data;
+ if (initialize)
+ {
+ var header = this.Header;
+ header.Type = HeaderType;
+ header.Code = Code;
+ header.Length = (byte)data.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 => 0x12;
+
+ ///
+ /// Gets the header of this packet.
+ ///
+ public C1HeaderWithSubCodeRef Header => new (this._data);
+
+ ///
+ /// Gets or sets the remaining seconds.
+ ///
+ public ushort RemainingSeconds
+ {
+ get => ReadUInt16LittleEndian(this._data[4..]);
+ set => WriteUInt16LittleEndian(this._data[4..], value);
+ }
+
+ ///
+ /// Gets or sets the player count.
+ ///
+ public byte PlayerCount
+ {
+ get => this._data[6];
+ set => this._data[6] = value;
+ }
+
+ ///
+ /// Gets the of the specified index.
+ ///
+ public PlayerPositionRef this[int index] => new (this._data[(8 + index * PlayerPositionRef.Length)..]);
+
+ ///
+ /// Performs an implicit conversion from a Span of bytes to a .
+ ///
+ /// The packet as span.
+ /// The packet as struct.
+ public static implicit operator DoppelgangerPlayInfoRef(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(DoppelgangerPlayInfoRef packet) => packet._data;
+
+ ///
+ /// Calculates the size of the packet for the specified count of .
+ ///
+ /// The count of from which the size will be calculated.
+
+ public static int GetRequiredSize(int playerPositionsCount) => playerPositionsCount * PlayerPositionRef.Length + 8;
+
+
+///
+/// The position of a player on the path..
+///
+public readonly ref struct PlayerPositionRef
+{
+ private readonly Span _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public PlayerPositionRef(Span data)
+ {
+ this._data = data;
+ }
+
+ ///
+ /// 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 => 4;
+
+ ///
+ /// Gets or sets the player id.
+ ///
+ public ushort PlayerId
+ {
+ get => ReadUInt16LittleEndian(this._data);
+ set => WriteUInt16LittleEndian(this._data, value);
+ }
+
+ ///
+ /// Gets or sets the map number of the player. It is ignored by the client.
+ ///
+ public byte MapNumber
+ {
+ get => this._data[2];
+ set => this._data[2] = value;
+ }
+
+ ///
+ /// Gets or sets the position index of the player on the path, between 0 (start) and 22 (magic circle).
+ ///
+ public byte Position
+ {
+ get => this._data[3];
+ set => this._data[3] = value;
+ }
+}
+}
+
+
+///
+/// Is sent by the server when: The doppelganger event ended for the player.
+/// Causes reaction on client side: The client stops the timer and the event music, and shows a message box with the result.
+///
+public readonly ref struct DoppelgangerResultRef
+{
+ private readonly Span _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerResultRef(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 DoppelgangerResultRef(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 => 0x13;
+
+ ///
+ /// 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 DoppelgangerResult.ResultType Result
+ {
+ get => (DoppelgangerResult.ResultType)this._data[4];
+ set => this._data[4] = (byte)value;
+ }
+
+ ///
+ /// Gets or sets the experience which the player got as reward. It is not shown by the client. The field is aligned to 4 bytes, because the client structure is not packed.
+ ///
+ public uint RewardExperience
+ {
+ 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 DoppelgangerResultRef(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(DoppelgangerResultRef packet) => packet._data;
+}
+
+
+///
+/// Is sent by the server when: A monster reached the magic circle during the doppelganger event.
+/// Causes reaction on client side: The client updates the counter of monsters which passed the magic circle.
+///
+public readonly ref struct DoppelgangerMonsterGoalRef
+{
+ private readonly Span _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public DoppelgangerMonsterGoalRef(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 DoppelgangerMonsterGoalRef(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 => 0x14;
+
+ ///
+ /// 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 => 6;
+
+ ///
+ /// Gets the header of this packet.
+ ///
+ public C1HeaderWithSubCodeRef Header => new (this._data);
+
+ ///
+ /// Gets or sets the number of monsters which may reach the magic circle until the event fails.
+ ///
+ public byte MaximumGoalCount
+ {
+ get => this._data[4];
+ set => this._data[4] = value;
+ }
+
+ ///
+ /// Gets or sets the number of monsters which reached the magic circle.
+ ///
+ public byte GoalCount
+ {
+ get => this._data[5];
+ set => this._data[5] = value;
+ }
+
+ ///
+ /// Performs an implicit conversion from a Span of bytes to a .
+ ///
+ /// The packet as span.
+ /// The packet as struct.
+ public static implicit operator DoppelgangerMonsterGoalRef(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(DoppelgangerMonsterGoalRef packet) => packet._data;
+}
+
+
///
/// Is sent by the server when: The server validated or changed the status of the MU Helper.
/// Causes reaction on client side: The client toggle the MU Helper status.
diff --git a/src/Persistence/Initialization/DataInitializationBase.cs b/src/Persistence/Initialization/DataInitializationBase.cs
index 0e16c8e635..6062d330c0 100644
--- a/src/Persistence/Initialization/DataInitializationBase.cs
+++ b/src/Persistence/Initialization/DataInitializationBase.cs
@@ -9,6 +9,7 @@ namespace MUnique.OpenMU.Persistence.Initialization;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
using MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
using MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
@@ -162,6 +163,13 @@ public async Task CreateInitialDataAsync(byte numberOfGameServers, bool createTe
plugInConfiguration.SetConfiguration(config, referenceHandler);
}
+ if (plugInType == typeof(DoppelgangerFeaturePlugIn))
+ {
+ // The default configuration of the plug-in can't reference the monsters of the
+ // event, because it's created without a game configuration.
+ plugInConfiguration.SetConfiguration(DoppelgangerEventDefinition.CreateDefault(this.GameConfiguration), referenceHandler);
+ }
+
if (plugInType == typeof(KanturuStartPlugIn))
{
// The default configuration of the plug-in can't reference the monsters of the
diff --git a/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs
new file mode 100644
index 0000000000..6fa8feb287
--- /dev/null
+++ b/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs
@@ -0,0 +1,120 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Persistence.Initialization.Updates;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Events;
+using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Items;
+using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Adds the doppelganger event configuration to an existing Season 6 database.
+///
+///
+/// It adds the Mirror of Dimensions and the Doppelganger Free Ticket as entrance tickets, the
+/// Sign of Dimensions which drops from monsters, the monsters, the reward chests and the s,
+/// opens the entrance window when talking to Lugard, and lets players who die inside the
+/// event maps respawn at Elvenland. Additionally, it reduces the entrance gates of the event maps
+/// to their walkable part, because players who got placed on a non-walkable coordinate were
+/// warped out of the event.
+///
+[PlugIn]
+[Display(Name = PlugInName, Description = PlugInDescription)]
+[Guid("8E4D2B17-6A3F-4C95-9D02-B7E15A6C3F48")]
+public class AddDoppelgangerDataUpdatePlugIn : UpdatePlugInBase
+{
+ ///
+ /// The plug-in name.
+ ///
+ internal const string PlugInName = "Add Doppelganger data";
+
+ ///
+ /// The plug-in description.
+ ///
+ internal const string PlugInDescription = "This update adds the doppelganger event configuration.";
+
+ private const short LugardNumber = 540;
+
+ ///
+ public override string Name => PlugInName;
+
+ ///
+ public override string Description => PlugInDescription;
+
+ ///
+ public override UpdateVersion Version => UpdateVersion.AddDoppelgangerData;
+
+ ///
+ public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id;
+
+ ///
+ public override bool IsMandatory => false;
+
+ ///
+ public override DateTime CreatedAt => new(2026, 09, 23, 0, 0, 0, DateTimeKind.Utc);
+
+ ///
+ protected override ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration)
+ {
+ if (new EventTicketItems(context, gameConfiguration).CreateDoppelgangerItems() is { } signOfDimensionsDropGroup)
+ {
+ foreach (var map in gameConfiguration.Maps)
+ {
+ map.DropItemGroups.Add(signOfDimensionsDropGroup);
+ }
+ }
+
+ if (gameConfiguration.Monsters.FirstOrDefault(monster => monster.Number == LugardNumber) is { } lugard)
+ {
+ lugard.NpcWindow = NpcWindow.LugardDoppelgangerEntry;
+ }
+
+ var elvenland = gameConfiguration.Maps.FirstOrDefault(map => map.Number == Elvenland.Number && map.Discriminator == 0);
+ foreach (var map in gameConfiguration.Maps.Where(map => map.Number is >= Doppelgaenger1.Number and <= Doppelgaenger4.Number))
+ {
+ map.SafezoneMap = elvenland;
+ if (map.ExitGates.FirstOrDefault(gate => gate.IsSpawnGate) is { } entrance)
+ {
+ (entrance.X1, entrance.Y1, entrance.X2, entrance.Y2) = GetWalkableEntranceArea(map.Number);
+ }
+ }
+
+ if (!gameConfiguration.Monsters.Any(monster => monster.Number == DoppelgangerMonsters.FirstMonsterNumber))
+ {
+ new DoppelgangerMonsters(context, gameConfiguration).Initialize();
+ }
+ else
+ {
+ new DoppelgangerMonsters(context, gameConfiguration).ConfigureRewardChests();
+ }
+
+ if (!gameConfiguration.MiniGameDefinitions.Any(definition => definition.Type == MiniGameType.Doppelganger))
+ {
+ new DoppelgangerInitializer(context, gameConfiguration).Initialize();
+ }
+
+ // The event accepts two different tickets, which are checked by the EnterDoppelgangerAction.
+ foreach (var definition in gameConfiguration.MiniGameDefinitions.Where(definition => definition.Type == MiniGameType.Doppelganger))
+ {
+ definition.TicketItem = null;
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ private static (byte X1, byte Y1, byte X2, byte Y2) GetWalkableEntranceArea(short mapNumber)
+ {
+ return mapNumber switch
+ {
+ Doppelgaenger1.Number => (194, 26, 199, 32),
+ Doppelgaenger2.Number => (134, 69, 139, 74),
+ Doppelgaenger3.Number => (106, 60, 111, 62),
+ Doppelgaenger4.Number => (92, 13, 97, 17),
+ _ => throw new ArgumentOutOfRangeException(nameof(mapNumber), mapNumber, null),
+ };
+ }
+}
diff --git a/src/Persistence/Initialization/Updates/UpdateVersion.cs b/src/Persistence/Initialization/Updates/UpdateVersion.cs
index cab7f06784..a946148541 100644
--- a/src/Persistence/Initialization/Updates/UpdateVersion.cs
+++ b/src/Persistence/Initialization/Updates/UpdateVersion.cs
@@ -579,4 +579,9 @@ public enum UpdateVersion
/// The version of the .
///
AddKanturuMapContent = 114,
+
+ ///
+ /// The version of the .
+ ///
+ AddDoppelgangerData = 115,
}
diff --git a/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerInitializer.cs
new file mode 100644
index 0000000000..fb6d3c5256
--- /dev/null
+++ b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerInitializer.cs
@@ -0,0 +1,67 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Events;
+
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps;
+
+///
+/// The initializer for the doppelganger event.
+///
+///
+/// There is one definition for each of the four event maps. They don't differ in their
+/// requirements, because the event map is chosen randomly when a party enters.
+/// Each party plays in its own instance of the map.
+/// The definitions don't define a ticket item, because the event accepts two different tickets,
+/// which are checked by the EnterDoppelgangerAction.
+///
+internal class DoppelgangerInitializer : InitializerBase
+{
+ private static readonly byte[] MapNumbers = [Doppelgaenger1.Number, Doppelgaenger2.Number, Doppelgaenger3.Number, Doppelgaenger4.Number];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The context.
+ /// The game configuration.
+ public DoppelgangerInitializer(IContext context, GameConfiguration gameConfiguration)
+ : base(context, gameConfiguration)
+ {
+ }
+
+ ///
+ public override void Initialize()
+ {
+ if (!this.GameConfiguration.Monsters.Any(monster => monster.Number == DoppelgangerMonsters.FirstMonsterNumber))
+ {
+ new DoppelgangerMonsters(this.Context, this.GameConfiguration).Initialize();
+ }
+
+ for (var i = 0; i < MapNumbers.Length; i++)
+ {
+ var gameLevel = i + 1;
+ var doppelganger = this.Context.CreateNew();
+ doppelganger.SetGuid((short)MiniGameType.Doppelganger, (short)gameLevel);
+ this.GameConfiguration.MiniGameDefinitions.Add(doppelganger);
+ doppelganger.Name = $"Doppelganger {gameLevel}";
+ doppelganger.Description = $"Event definition for the doppelganger event on map {MapNumbers[i]}.";
+ doppelganger.EnterDuration = TimeSpan.FromSeconds(30);
+ doppelganger.GameDuration = TimeSpan.FromMinutes(10);
+ doppelganger.ExitDuration = TimeSpan.FromMinutes(1);
+ doppelganger.MaximumPlayerCount = 5;
+ doppelganger.MinimumCharacterLevel = 1;
+ doppelganger.MaximumCharacterLevel = 400;
+ doppelganger.MinimumSpecialCharacterLevel = 1;
+ doppelganger.MaximumSpecialCharacterLevel = 400;
+ doppelganger.Entrance = this.GameConfiguration.Maps.First(m => m.Number == MapNumbers[i]).ExitGates.Single(g => g.IsSpawnGate);
+ doppelganger.Type = MiniGameType.Doppelganger;
+ doppelganger.TicketItem = null;
+ doppelganger.GameLevel = (byte)gameLevel;
+ doppelganger.MapCreationPolicy = MiniGameMapCreationPolicy.OnePerParty;
+ doppelganger.SaveRankingStatistics = false;
+ doppelganger.AllowParty = true;
+ }
+ }
+}
diff --git a/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs
new file mode 100644
index 0000000000..d952bb7cb9
--- /dev/null
+++ b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs
@@ -0,0 +1,172 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Events;
+
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.Persistence.Initialization.Skills;
+
+///
+/// The initializer for the monsters of the doppelganger event.
+///
+///
+/// The values are default values, which can be adjusted in the admin panel.
+///
+internal class DoppelgangerMonsters : InitializerBase
+{
+ ///
+ /// The number of the first monster of the doppelganger event.
+ ///
+ internal const short FirstMonsterNumber = 529;
+
+ ///
+ /// The number of the interim reward chest.
+ ///
+ internal const short InterimRewardChestNumber = 541;
+
+ ///
+ /// The number of the final reward chest.
+ ///
+ internal const short FinalRewardChestNumber = 542;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The context.
+ /// The game configuration.
+ public DoppelgangerMonsters(IContext context, GameConfiguration gameConfiguration)
+ : base(context, gameConfiguration)
+ {
+ }
+
+ ///
+ public override void Initialize()
+ {
+ this.CreateMonster(529, "Terrible Butcher", 20, 2800, 125, 135, 83, 2000, 1500, 3, 6, 7, 500, 1600, 0, null, 15, 254);
+ this.CreateMonster(530, "Mad Butcher", 18, 2300, 110, 120, 73, 1000, 1000, 3, 6, 7, 500, 1600, 0, null, 15, 254);
+ this.CreateMonster(531, "Ice Walker", 19, 1500, 85, 90, 60, 800, 1000, 3, 8, 10, 400, 2000, 2, null, 15, 15);
+ this.CreateMonster(532, "Larva", 16, 1000, 75, 80, 55, 800, 900, 3, 2, 7, 400, 2000, 2, null, 15, 15);
+ this.CreateMonster(533, "Doppelganger", 15, 600, 80, 85, 45, 1000, 900, 3, 1, 3, 400, 1600, 2, SkillNumber.DoppelgangerSelfExplosion, 10, 10);
+ this.CreateMonster(534, "Doppelganger Elf", 14, 500, 65, 70, 30, 700, 500, 2, 5, 6, 400, 1400, 2, SkillNumber.MultiShot, 10, 10);
+ this.CreateMonster(535, "Doppelganger Knight", 14, 500, 65, 70, 30, 700, 500, 2, 1, 3, 400, 2200, 2, SkillNumber.TwistingSlash, 10, 10);
+ this.CreateMonster(536, "Doppelganger Wizard", 14, 500, 65, 70, 30, 700, 500, 2, 5, 4, 400, 2200, 2, SkillNumber.IceStorm, 10, 10);
+ this.CreateMonster(537, "Doppelganger Magic Gladiator", 14, 500, 65, 70, 30, 700, 500, 2, 4, 4, 400, 1400, 2, SkillNumber.PowerSlash, 10, 10);
+ this.CreateMonster(538, "Doppelganger Dark Lord", 17, 900, 85, 90, 55, 900, 800, 3, 4, 6, 400, 1600, 2, SkillNumber.Earthshake, 15, 15);
+ this.CreateMonster(539, "Doppelganger Summoner", 14, 500, 65, 70, 30, 500, 300, 3, 2, 4, 400, 1600, 2, SkillNumber.LightningShock, 10, 10);
+
+ this.ConfigureRewardChests();
+ }
+
+ ///
+ /// Configures the reward chests, which were created as passive NPCs before, so that they can
+ /// be opened by attacking them. They drop jewels and with a small chance a Loch's Feather
+ /// or Crest of Monarch.
+ ///
+ internal void ConfigureRewardChests()
+ {
+ this.ConfigureRewardChest(InterimRewardChestNumber, 2, true);
+ this.ConfigureRewardChest(FinalRewardChestNumber, 5, false);
+ }
+
+ private void ConfigureRewardChest(short number, int numberOfDrops, bool isJewelGuaranteed)
+ {
+ if (this.GameConfiguration.Monsters.FirstOrDefault(monster => monster.Number == number) is not { } chest
+ || chest.ObjectKind == NpcObjectKind.Destructible)
+ {
+ return;
+ }
+
+ chest.ObjectKind = NpcObjectKind.Destructible;
+ chest.NumberOfMaximumItemDrops = numberOfDrops;
+ chest.RespawnDelay = TimeSpan.Zero;
+ var attributes = new Dictionary
+ {
+ { Stats.Level, 2 },
+ { Stats.MaximumHealth, 10 },
+ { Stats.DefenseBase, 10 },
+ { Stats.DefenseRatePvm, 10 },
+ };
+ chest.AddAttributes(attributes, this.Context, this.GameConfiguration);
+
+ var jewels = new[] { (12, 15), (14, 13), (14, 14), (14, 16), (14, 22) };
+ this.AddDropGroup(chest, 1, "Jewel", isJewelGuaranteed ? 1.0 : 0.9, 0, jewels);
+ this.AddDropGroup(chest, 2, "Loch's Feather", 0.08, 0, (13, 14));
+ this.AddDropGroup(chest, 3, "Crest of Monarch", 0.02, 1, (13, 14));
+ }
+
+ private void AddDropGroup(MonsterDefinition chest, short index, string name, double chance, byte itemLevel, params (int Group, int Number)[] items)
+ {
+ var group = this.Context.CreateNew();
+ group.SetGuid(chest.Number, index);
+ group.Description = $"{chest.Designation}: {name}";
+ group.Chance = chance;
+ group.ItemLevel = itemLevel;
+ group.Monster = chest;
+ foreach (var (itemGroup, itemNumber) in items)
+ {
+ group.PossibleItems.Add(this.GameConfiguration.Items.First(item => item.Group == itemGroup && item.Number == itemNumber));
+ }
+
+ chest.DropItemGroups.Add(group);
+ this.GameConfiguration.DropItemGroups.Add(group);
+ }
+
+ private void CreateMonster(
+ short number,
+ string designation,
+ byte level,
+ int health,
+ int minimumDamage,
+ int maximumDamage,
+ int defense,
+ int attackRate,
+ int defenseRate,
+ byte moveRange,
+ byte attackRange,
+ short viewRange,
+ int moveDelayMs,
+ int attackDelayMs,
+ byte attribute,
+ SkillNumber? attackSkill,
+ byte resistance,
+ byte lightningResistance)
+ {
+ var monster = this.Context.CreateNew();
+ this.GameConfiguration.Monsters.Add(monster);
+ monster.Number = number;
+ monster.Designation = designation;
+ monster.MoveRange = moveRange;
+ monster.AttackRange = attackRange;
+ monster.ViewRange = viewRange;
+ monster.MoveDelay = TimeSpan.FromMilliseconds(moveDelayMs);
+ monster.AttackDelay = TimeSpan.FromMilliseconds(attackDelayMs);
+ monster.RespawnDelay = TimeSpan.FromSeconds(10);
+ monster.Attribute = attribute;
+ monster.NumberOfMaximumItemDrops = 1;
+ if (attackSkill is { } skillNumber)
+ {
+ monster.AttackSkill = this.GameConfiguration.Skills.FirstOrDefault(s => s.Number == (short)skillNumber);
+ }
+
+ var attributes = new Dictionary
+ {
+ { Stats.Level, level },
+ { Stats.MaximumHealth, health },
+ { Stats.MinimumPhysBaseDmg, minimumDamage },
+ { Stats.MaximumPhysBaseDmg, maximumDamage },
+ { Stats.DefenseBase, defense },
+ { Stats.AttackRatePvm, attackRate },
+ { Stats.DefenseRatePvm, defenseRate },
+ { Stats.PoisonResistance, resistance / 255f },
+ { Stats.IceResistance, resistance / 255f },
+ { Stats.LightningResistance, lightningResistance / 255f },
+ { Stats.FireResistance, resistance / 255f },
+ };
+
+ monster.AddAttributes(attributes, this.Context, this.GameConfiguration);
+ monster.SetGuid(monster.Number);
+ }
+}
diff --git a/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs
index ee29fe4322..a7a0ad04a5 100644
--- a/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs
+++ b/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs
@@ -91,6 +91,7 @@ public override void Initialize()
new ChaosCastleInitializer(this.Context, this.GameConfiguration).Initialize();
new CastleSiegeInitializer(this.Context, this.GameConfiguration).Initialize();
new KanturuInitializer(this.Context, this.GameConfiguration).Initialize();
+ new DoppelgangerInitializer(this.Context, this.GameConfiguration).Initialize();
}
///
diff --git a/src/Persistence/Initialization/VersionSeasonSix/Gates.cs b/src/Persistence/Initialization/VersionSeasonSix/Gates.cs
index 6d14dcb324..79bf92d64d 100644
--- a/src/Persistence/Initialization/VersionSeasonSix/Gates.cs
+++ b/src/Persistence/Initialization/VersionSeasonSix/Gates.cs
@@ -423,10 +423,11 @@ private IDictionary CreateTargetGates(IDictionary
+ /// Creates the items of the doppelganger event, which don't exist yet:
+ /// The Mirror of Dimensions and the Doppelganger Free Ticket, which allow to enter the event,
+ /// and the Sign of Dimensions, of which five transform into a Mirror of Dimensions.
+ ///
+ /// The drop item group of the Sign of Dimensions, if it was created.
+ internal DropItemGroup? CreateDoppelgangerItems()
+ {
+ if (!this.GameConfiguration.Items.Any(item => item is { Group: 14, Number: 111 }))
+ {
+ this.CreateEventItem(111, 14, 1, 1, "Mirror of Dimensions", false);
+ }
+
+ if (!this.GameConfiguration.Items.Any(item => item is { Group: 13, Number: 125 }))
+ {
+ this.CreateEventItem(125, 13, 1, 1, "Doppelganger Free Ticket", false);
+ }
+
+ if (this.GameConfiguration.Items.Any(item => item is { Group: 14, Number: 110 }))
+ {
+ return null;
+ }
+
+ // It's stackable up to five pieces, which transform into a Mirror of Dimensions.
+ var signOfDimensions = this.CreateEventItem(110, 14, 1, 1, "Sign of Dimensions", true);
+ signOfDimensions.Durability = 5;
+
+ var dropItemGroup = this.Context.CreateNew();
+ dropItemGroup.SetGuid(14, 110);
+ dropItemGroup.Chance = 0.001;
+ dropItemGroup.Description = "Sign of Dimensions";
+ dropItemGroup.MinimumMonsterLevel = 32;
+ dropItemGroup.PossibleItems.Add(signOfDimensions);
+ this.GameConfiguration.DropItemGroups.Add(dropItemGroup);
+ return dropItemGroup;
+ }
+
private ItemDefinition CreateEventItem(byte number, byte group, byte width, byte height, string name, bool dropsFromMonsters, byte maxItemLevel = 0, params byte[] dropLevels)
{
var item = this.Context.CreateNew();
diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger1.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger1.cs
index 23f51f7d47..f8c3aaa45f 100644
--- a/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger1.cs
+++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger1.cs
@@ -36,4 +36,10 @@ public Doppelgaenger1(IContext context, GameConfiguration gameConfiguration)
///
protected override string MapName => Name;
+
+ ///
+ /// Gets the safezone map number. Players who die inside the doppelganger event map
+ /// respawn at Elvenland.
+ ///
+ protected override byte SafezoneMapNumber => Elvenland.Number;
}
\ No newline at end of file
diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger2.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger2.cs
index 6ddae05808..19f047cedc 100644
--- a/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger2.cs
+++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger2.cs
@@ -36,4 +36,10 @@ public Doppelgaenger2(IContext context, GameConfiguration gameConfiguration)
///
protected override string MapName => Name;
+
+ ///
+ /// Gets the safezone map number. Players who die inside the doppelganger event map
+ /// respawn at Elvenland.
+ ///
+ protected override byte SafezoneMapNumber => Elvenland.Number;
}
\ No newline at end of file
diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger3.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger3.cs
index 3d5e080804..9cf5a14f98 100644
--- a/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger3.cs
+++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger3.cs
@@ -37,6 +37,12 @@ public Doppelgaenger3(IContext context, GameConfiguration gameConfiguration)
///
protected override string MapName => Name;
+ ///
+ /// Gets the safezone map number. Players who die inside the doppelganger event map
+ /// respawn at Elvenland.
+ ///
+ protected override byte SafezoneMapNumber => Elvenland.Number;
+
///
protected override void AdditionalInitialization(GameMapDefinition mapDefinition)
{
diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger4.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger4.cs
index aa9effbc67..3e9912ac3a 100644
--- a/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger4.cs
+++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/Doppelgaenger4.cs
@@ -36,4 +36,10 @@ public Doppelgaenger4(IContext context, GameConfiguration gameConfiguration)
///
protected override string MapName => Name;
+
+ ///
+ /// Gets the safezone map number. Players who die inside the doppelganger event map
+ /// respawn at Elvenland.
+ ///
+ protected override byte SafezoneMapNumber => Elvenland.Number;
}
\ No newline at end of file
diff --git a/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs b/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs
index e1c16ac59a..68276ebf32 100644
--- a/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs
+++ b/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs
@@ -771,6 +771,7 @@ public override void Initialize()
var def = this.Context.CreateNew();
def.Number = 540;
def.Designation = "Lugard";
+ def.NpcWindow = NpcWindow.LugardDoppelgangerEntry;
def.ObjectKind = NpcObjectKind.PassiveNpc;
this.GameConfiguration.Monsters.Add(def);
def.SetGuid(def.Number);
diff --git a/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs b/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs
index 0aa5975efb..2d2fc5a2af 100644
--- a/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs
+++ b/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs
@@ -5610,6 +5610,144 @@ public void ChainLightningHitInfo_PacketSizeValidation()
"Field 'TargetCount' has invalid negative index");
}
+ ///
+ /// Tests the packet size calculation for DoppelgangerEnterResult.
+ ///
+ [Test]
+ public void DoppelgangerEnterResult_PacketSizeValidation()
+ {
+ // Fixed-length packet validation
+ const int expectedLength = 5;
+ var actualLength = DoppelgangerEnterResultRef.Length;
+
+ Assert.That(actualLength, Is.EqualTo(expectedLength),
+ "Packet length mismatch: declared length does not match calculated size");
+
+ // Validate field 'Result' boundary
+ Assert.That(4 + 1, Is.LessThanOrEqualTo(expectedLength),
+ "Field 'Result' exceeds packet boundary");
+ }
+
+ ///
+ /// Tests the packet size calculation for DoppelgangerMonsterPosition.
+ ///
+ [Test]
+ public void DoppelgangerMonsterPosition_PacketSizeValidation()
+ {
+ // Fixed-length packet validation
+ const int expectedLength = 5;
+ var actualLength = DoppelgangerMonsterPositionRef.Length;
+
+ Assert.That(actualLength, Is.EqualTo(expectedLength),
+ "Packet length mismatch: declared length does not match calculated size");
+
+ // Validate field 'Position' boundary
+ Assert.That(4 + 1, Is.LessThanOrEqualTo(expectedLength),
+ "Field 'Position' exceeds packet boundary");
+ }
+
+ ///
+ /// Tests the packet size calculation for DoppelgangerStateUpdate.
+ ///
+ [Test]
+ public void DoppelgangerStateUpdate_PacketSizeValidation()
+ {
+ // Fixed-length packet validation
+ const int expectedLength = 5;
+ var actualLength = DoppelgangerStateUpdateRef.Length;
+
+ Assert.That(actualLength, Is.EqualTo(expectedLength),
+ "Packet length mismatch: declared length does not match calculated size");
+
+ // Validate field 'State' boundary
+ Assert.That(4 + 1, Is.LessThanOrEqualTo(expectedLength),
+ "Field 'State' exceeds packet boundary");
+ }
+
+ ///
+ /// Tests the packet size calculation for DoppelgangerIceWalkerState.
+ ///
+ [Test]
+ public void DoppelgangerIceWalkerState_PacketSizeValidation()
+ {
+ // Fixed-length packet validation
+ const int expectedLength = 6;
+ var actualLength = DoppelgangerIceWalkerStateRef.Length;
+
+ Assert.That(actualLength, Is.EqualTo(expectedLength),
+ "Packet length mismatch: declared length does not match calculated size");
+
+ // Validate field 'State' boundary
+ Assert.That(4 + 1, Is.LessThanOrEqualTo(expectedLength),
+ "Field 'State' exceeds packet boundary");
+
+ // Validate field 'Position' boundary
+ Assert.That(5 + 1, Is.LessThanOrEqualTo(expectedLength),
+ "Field 'Position' exceeds packet boundary");
+ }
+
+ ///
+ /// Tests the packet size calculation for DoppelgangerPlayInfo.
+ ///
+ [Test]
+ public void DoppelgangerPlayInfo_PacketSizeValidation()
+ {
+ // Basic packet validation
+ // Validate header type and field boundaries
+
+ // Field 'RemainingSeconds' starts at index 4 with size 2
+ Assert.That(4, Is.GreaterThanOrEqualTo(0),
+ "Field 'RemainingSeconds' has invalid negative index");
+
+ // Field 'PlayerCount' starts at index 6 with size 1
+ Assert.That(6, Is.GreaterThanOrEqualTo(0),
+ "Field 'PlayerCount' has invalid negative index");
+ }
+
+ ///
+ /// Tests the packet size calculation for DoppelgangerResult.
+ ///
+ [Test]
+ public void DoppelgangerResult_PacketSizeValidation()
+ {
+ // Fixed-length packet validation
+ const int expectedLength = 12;
+ var actualLength = DoppelgangerResultRef.Length;
+
+ Assert.That(actualLength, Is.EqualTo(expectedLength),
+ "Packet length mismatch: declared length does not match calculated size");
+
+ // Validate field 'Result' boundary
+ Assert.That(4 + 1, Is.LessThanOrEqualTo(expectedLength),
+ "Field 'Result' exceeds packet boundary");
+
+ // Validate field 'RewardExperience' boundary
+ Assert.That(8 + 4, Is.LessThanOrEqualTo(expectedLength),
+ "Field 'RewardExperience' exceeds packet boundary");
+ }
+
+ ///
+ /// Tests the packet size calculation for DoppelgangerMonsterGoal.
+ ///
+ [Test]
+ public void DoppelgangerMonsterGoal_PacketSizeValidation()
+ {
+ // Fixed-length packet validation
+ const int expectedLength = 6;
+ var actualLength = DoppelgangerMonsterGoalRef.Length;
+
+ Assert.That(actualLength, Is.EqualTo(expectedLength),
+ "Packet length mismatch: declared length does not match calculated size");
+
+ // Validate field 'MaximumGoalCount' boundary
+ Assert.That(4 + 1, Is.LessThanOrEqualTo(expectedLength),
+ "Field 'MaximumGoalCount' exceeds packet boundary");
+
+ // Validate field 'GoalCount' boundary
+ Assert.That(5 + 1, Is.LessThanOrEqualTo(expectedLength),
+ "Field 'GoalCount' exceeds packet boundary");
+ }
+
///
/// Tests the packet size calculation for MuHelperStatusUpdate.
///
diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs
new file mode 100644
index 0000000000..506f2afe6e
--- /dev/null
+++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs
@@ -0,0 +1,185 @@
+//
+// 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.GameLogic.MiniGames.Doppelganger;
+using MUnique.OpenMU.Persistence.Initialization.Updates;
+using MUnique.OpenMU.Persistence.InMemory;
+
+///
+/// Tests the data of the doppelganger event.
+///
+[TestFixture]
+internal class DoppelgangerDataTest
+{
+ private const short LugardNumber = 540;
+ private const short ElvenlandNumber = 51;
+ private static readonly short[] EventMapNumbers = [65, 66, 67, 68];
+
+ ///
+ /// Tests that a new season 6 database contains the doppelganger event.
+ ///
+ [Test]
+ public async Task NewDatabaseContainsEventAsync()
+ {
+ var gameConfiguration = await CreateSeason6ConfigurationAsync().ConfigureAwait(false);
+
+ AssertEventData(gameConfiguration);
+ }
+
+ ///
+ /// Tests that a new database contains the configuration of the event, which references the
+ /// monsters instead of containing copies of them, so that it can be edited in the admin panel.
+ ///
+ [Test]
+ public async Task NewDatabaseContainsEventConfigurationAsync()
+ {
+ var gameConfiguration = await CreateSeason6ConfigurationAsync().ConfigureAwait(false);
+
+ var plugInConfiguration = gameConfiguration.PlugInConfigurations.Single(c => c.TypeId == typeof(DoppelgangerFeaturePlugIn).GUID);
+ var json = plugInConfiguration.CustomConfiguration;
+ TestContext.Out.WriteLine(json?[..Math.Min(json.Length, 1500)]);
+
+ Assert.That(json, Is.Not.Null.And.Not.Empty);
+ Assert.That(json, Does.Contain("\"HerdMonsters\""));
+ Assert.That(json, Does.Contain("\"Paths\""));
+ Assert.That(json, Does.Not.Contain("\"Designation\""), "The monsters should be referenced instead of being serialized completely.");
+ }
+
+ ///
+ /// Tests that the update adds the doppelganger event to a database which was created
+ /// before it existed, and that applying it twice doesn't duplicate anything.
+ ///
+ [Test]
+ public async Task UpdateAddsEventToExistingDatabaseAsync()
+ {
+ var contextProvider = new InMemoryPersistenceContextProvider();
+ var dataInitialization = new VersionSeasonSix.DataInitialization(contextProvider, new NullLoggerFactory());
+ await dataInitialization.CreateInitialDataAsync(1, true).ConfigureAwait(false);
+
+ using var context = contextProvider.CreateNewContext();
+ var gameConfiguration = (await context.GetAsync().ConfigureAwait(false)).First();
+
+ // Revert the database to the state before the doppelganger event existed.
+ foreach (var definition in gameConfiguration.MiniGameDefinitions.Where(d => d.Type == MiniGameType.Doppelganger).ToList())
+ {
+ gameConfiguration.MiniGameDefinitions.Remove(definition);
+ }
+
+ gameConfiguration.Items.Remove(gameConfiguration.Items.Single(item => item is { Group: 14, Number: 111 }));
+ gameConfiguration.Items.Remove(gameConfiguration.Items.Single(item => item is { Group: 13, Number: 125 }));
+ var signDropGroup = gameConfiguration.DropItemGroups.Single(group => group.PossibleItems.Any(item => item is { Group: 14, Number: 110 }));
+ gameConfiguration.DropItemGroups.Remove(signDropGroup);
+ foreach (var map in gameConfiguration.Maps)
+ {
+ map.DropItemGroups.Remove(signDropGroup);
+ }
+
+ gameConfiguration.Items.Remove(gameConfiguration.Items.Single(item => item is { Group: 14, Number: 110 }));
+ foreach (var monster in gameConfiguration.Monsters.Where(monster => monster.Number is >= 529 and <= 539).ToList())
+ {
+ gameConfiguration.Monsters.Remove(monster);
+ }
+
+ gameConfiguration.Monsters.Single(monster => monster.Number == LugardNumber).NpcWindow = NpcWindow.Undefined;
+ foreach (var map in gameConfiguration.Maps.Where(map => EventMapNumbers.Contains(map.Number)))
+ {
+ map.SafezoneMap = map;
+ }
+
+ // The previous entrance gate of the first event map, which contains non-walkable coordinates.
+ var entrance = gameConfiguration.Maps.Single(map => map.Number == 65).ExitGates.Single(gate => gate.IsSpawnGate);
+ (entrance.X1, entrance.Y1, entrance.X2, entrance.Y2) = ((byte)193, (byte)26, (byte)200, (byte)32);
+
+ var update = new AddDoppelgangerDataUpdatePlugIn();
+ await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false);
+ await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false);
+
+ AssertEventData(gameConfiguration);
+ }
+
+ ///
+ /// Tests that the update adds the monsters to a database which already got the event
+ /// definitions by an earlier version of the update, without duplicating the definitions.
+ ///
+ [Test]
+ public async Task UpdateAddsMissingMonstersAsync()
+ {
+ var contextProvider = new InMemoryPersistenceContextProvider();
+ var dataInitialization = new VersionSeasonSix.DataInitialization(contextProvider, new NullLoggerFactory());
+ await dataInitialization.CreateInitialDataAsync(1, true).ConfigureAwait(false);
+
+ using var context = contextProvider.CreateNewContext();
+ var gameConfiguration = (await context.GetAsync().ConfigureAwait(false)).First();
+ foreach (var monster in gameConfiguration.Monsters.Where(monster => monster.Number is >= 529 and <= 539).ToList())
+ {
+ gameConfiguration.Monsters.Remove(monster);
+ }
+
+ var mirror = gameConfiguration.Items.Single(item => item is { Group: 14, Number: 111 });
+ foreach (var definition in gameConfiguration.MiniGameDefinitions.Where(d => d.Type == MiniGameType.Doppelganger))
+ {
+ definition.TicketItem = mirror;
+ }
+
+ await new AddDoppelgangerDataUpdatePlugIn().ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false);
+
+ AssertEventData(gameConfiguration);
+ }
+
+ private static async Task CreateSeason6ConfigurationAsync()
+ {
+ var contextProvider = new InMemoryPersistenceContextProvider();
+ var dataInitialization = new VersionSeasonSix.DataInitialization(contextProvider, new NullLoggerFactory());
+ await dataInitialization.CreateInitialDataAsync(1, true).ConfigureAwait(false);
+
+ using var context = contextProvider.CreateNewContext();
+ return (await context.GetAsync().ConfigureAwait(false)).First();
+ }
+
+ private static void AssertEventData(GameConfiguration gameConfiguration)
+ {
+ Assert.That(gameConfiguration.Items.Count(item => item is { Group: 14, Number: 111 }), Is.EqualTo(1), "Mirror of Dimensions");
+ Assert.That(gameConfiguration.Items.Count(item => item is { Group: 13, Number: 125 }), Is.EqualTo(1), "Doppelganger Free Ticket");
+ var sign = gameConfiguration.Items.Single(item => item is { Group: 14, Number: 110 });
+ Assert.That(sign.Durability, Is.EqualTo(5), "A stack of five signs transforms into a mirror.");
+ var signDropGroup = gameConfiguration.DropItemGroups.Single(group => group.PossibleItems.Contains(sign));
+ Assert.That(gameConfiguration.Maps.Single(map => map.Number == 0 && map.Discriminator == 0).DropItemGroups, Does.Contain(signDropGroup));
+ var definitions = gameConfiguration.MiniGameDefinitions.Where(d => d.Type == MiniGameType.Doppelganger).ToList();
+
+ Assert.That(definitions.Select(d => (short)d.GameLevel), Is.EquivalentTo(new short[] { 1, 2, 3, 4 }));
+ Assert.That(definitions.Select(d => d.Entrance?.Map?.Number), Is.EquivalentTo(EventMapNumbers.Select(n => (short?)n)));
+ Assert.That(definitions, Has.All.Matches(d => d.MapCreationPolicy == MiniGameMapCreationPolicy.OnePerParty));
+ Assert.That(definitions, Has.All.Matches(d => d.TicketItem is null));
+ Assert.That(
+ gameConfiguration.Monsters.Where(monster => monster.Number is >= 529 and <= 539).Select(monster => monster.Number),
+ Is.EquivalentTo(Enumerable.Range(529, 11).Select(n => (short)n)));
+ foreach (var chestNumber in new short[] { 541, 542 })
+ {
+ var chest = gameConfiguration.Monsters.Single(monster => monster.Number == chestNumber);
+ Assert.That(chest.ObjectKind, Is.EqualTo(NpcObjectKind.Destructible));
+ Assert.That(chest.DropItemGroups, Has.Count.EqualTo(3));
+ Assert.That(chest.NumberOfMaximumItemDrops, Is.GreaterThan(0));
+ }
+
+ Assert.That(gameConfiguration.Monsters.Single(monster => monster.Number == LugardNumber).NpcWindow, Is.EqualTo(NpcWindow.LugardDoppelgangerEntry));
+ Assert.That(
+ gameConfiguration.Maps.Where(map => EventMapNumbers.Contains(map.Number)).Select(map => map.SafezoneMap?.Number),
+ Has.All.EqualTo(ElvenlandNumber));
+ Assert.That(
+ gameConfiguration.Maps.Where(map => EventMapNumbers.Contains(map.Number))
+ .Select(map => map.ExitGates.Single(gate => gate.IsSpawnGate))
+ .Select(gate => (gate.Map!.Number, gate.X1, gate.Y1, gate.X2, gate.Y2)),
+ Is.EquivalentTo(new (short, byte, byte, byte, byte)[]
+ {
+ (65, 194, 26, 199, 32),
+ (66, 134, 69, 139, 74),
+ (67, 106, 60, 111, 62),
+ (68, 92, 13, 97, 17),
+ }));
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs
new file mode 100644
index 0000000000..3eea9e66cf
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.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.GameLogic.MiniGames.Doppelganger;
+using MUnique.OpenMU.Pathfinding;
+using NUnit.Framework;
+
+///
+/// Tests for the .
+///
+[TestFixture]
+public class DoppelgangerEventDefinitionTest
+{
+ private readonly DoppelgangerEventDefinition _definition = DoppelgangerEventDefinition.CreateDefault(CreateGameConfiguration());
+
+ ///
+ /// Tests that the default definition contains a complete path for each of the four event maps.
+ ///
+ [Test]
+ public void DefaultPathsAreComplete()
+ {
+ Assert.That(this._definition.Paths.Select(path => path.MapNumber), Is.EquivalentTo(new short[] { 65, 66, 67, 68 }));
+ Assert.That(this._definition.Paths, Has.All.Matches(path => path.Areas.Count == IDoppelgangerEventViewPlugIn.MaximumPathPosition + 1));
+ }
+
+ ///
+ /// Tests the position index of points on the path of the first map.
+ ///
+ /// The x coordinate.
+ /// The y coordinate.
+ /// The expected position index.
+ [TestCase(225, 103, 0)]
+ [TestCase(197, 27, 22)]
+ [TestCase(197, 46, 17)]
+ [TestCase(10, 10, 0)]
+ public void PathPosition(byte x, byte y, int expectedPosition)
+ {
+ Assert.That(this._definition.GetPathPosition(65, new Point(x, y)), Is.EqualTo(expectedPosition));
+ }
+
+ ///
+ /// Tests that the lower bounds of a path area are exclusive and the upper bounds are inclusive.
+ ///
+ [Test]
+ public void PathAreaBounds()
+ {
+ var area = new DoppelgangerPathArea(10, 20, 15, 25);
+
+ Assert.That(area.Contains(new Point(10, 22)), Is.False);
+ Assert.That(area.Contains(new Point(12, 20)), Is.False);
+ Assert.That(area.Contains(new Point(15, 25)), Is.True);
+ Assert.That(DoppelgangerMonsterIntelligence.GetCenter(area), Is.EqualTo(new Point(12, 22)));
+ }
+
+ ///
+ /// Tests the base count of monsters of a herd, which grows with the elapsed game time.
+ ///
+ /// The elapsed game time in seconds.
+ /// The expected base count.
+ [TestCase(0, 1)]
+ [TestCase(179, 1)]
+ [TestCase(180, 2)]
+ [TestCase(359, 2)]
+ [TestCase(360, 3)]
+ [TestCase(600, 3)]
+ public void HerdBaseCount(int elapsedSeconds, int expectedCount)
+ {
+ Assert.That(this._definition.GetHerdBaseCount(TimeSpan.FromSeconds(elapsedSeconds)), Is.EqualTo(expectedCount));
+ }
+
+ ///
+ /// Tests that the multipliers of the monsters are chosen by the highest player level, rounded up to the next
+ /// range of fifty levels, and that levels above the last range use the last one.
+ ///
+ /// The highest player level, including the master level.
+ /// The expected maximum player level of the chosen multipliers.
+ [TestCase(1, 50)]
+ [TestCase(50, 50)]
+ [TestCase(51, 100)]
+ [TestCase(400, 400)]
+ [TestCase(401, 450)]
+ [TestCase(800, 800)]
+ [TestCase(1000, 800)]
+ public void MonsterScalingByPlayerLevel(int playerLevel, int expectedMaximumPlayerLevel)
+ {
+ Assert.That(this._definition.GetMonsterScaling(playerLevel)?.MaximumPlayerLevel, Is.EqualTo(expectedMaximumPlayerLevel));
+ }
+
+ ///
+ /// Tests that the default multipliers contain a value for one to five players.
+ ///
+ [Test]
+ public void DefaultMonsterScalingsAreComplete()
+ {
+ Assert.That(this._definition.MonsterScalings, Has.Count.EqualTo(16));
+ Assert.That(
+ this._definition.MonsterScalings,
+ Has.All.Matches(scaling =>
+ scaling.LevelMultipliers.Count == 5
+ && scaling.HealthMultipliers.Count == 5
+ && scaling.DamageMultipliers.Count == 5
+ && scaling.DefenseMultipliers.Count == 5));
+ }
+
+ ///
+ /// Tests that the default multipliers of the monsters don't decrease with a higher player level
+ /// or with more players, so that the monsters of stronger parties are never weaker.
+ ///
+ [Test]
+ public void DefaultMonsterScalingsDontDecrease()
+ {
+ var scalings = this._definition.MonsterScalings.OrderBy(scaling => scaling.MaximumPlayerLevel).ToList();
+ foreach (var selector in new Func>[] { s => s.LevelMultipliers, s => s.HealthMultipliers, s => s.DamageMultipliers, s => s.DefenseMultipliers })
+ {
+ for (var i = 0; i < scalings.Count; i++)
+ {
+ var multipliers = selector(scalings[i]);
+ Assert.That(multipliers, Is.Ordered.Ascending, $"By player count at level {scalings[i].MaximumPlayerLevel}");
+ if (i > 0)
+ {
+ Assert.That(multipliers.Zip(selector(scalings[i - 1])).All(pair => pair.First >= pair.Second), $"By player level at level {scalings[i].MaximumPlayerLevel}");
+ }
+ }
+ }
+ }
+
+ ///
+ /// Tests that there is no position on a map without a path.
+ ///
+ [Test]
+ public void NoPositionOnOtherMaps()
+ {
+ Assert.That(this._definition.GetPathPosition(0, new Point(225, 103)), Is.Zero);
+ }
+
+ private static GameConfiguration CreateGameConfiguration()
+ {
+ var gameConfiguration = new Mock();
+ gameConfiguration.Setup(c => c.Monsters).Returns(new List());
+ return gameConfiguration.Object;
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/DoppelgangerMonsterIntelligenceTest.cs b/tests/MUnique.OpenMU.Tests/DoppelgangerMonsterIntelligenceTest.cs
new file mode 100644
index 0000000000..76aba95fa9
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/DoppelgangerMonsterIntelligenceTest.cs
@@ -0,0 +1,127 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using Microsoft.Extensions.Logging.Abstractions;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+using MUnique.OpenMU.GameLogic.NPC;
+using MUnique.OpenMU.Pathfinding;
+using MonsterAttribute = MUnique.OpenMU.Persistence.BasicModel.MonsterAttribute;
+using MonsterDefinition = MUnique.OpenMU.Persistence.BasicModel.MonsterDefinition;
+
+///
+/// Tests for the .
+///
+[TestFixture]
+public class DoppelgangerMonsterIntelligenceTest
+{
+ ///
+ /// A short path with three overlapping areas along the x axis. The last one is the magic circle.
+ ///
+ private static readonly IList Path =
+ [
+ new(100, 100, 110, 110),
+ new(108, 100, 118, 110),
+ new(116, 100, 126, 110),
+ ];
+
+ private int _reachedMagicCircleCount;
+
+ ///
+ /// Sets up the test.
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ this._reachedMagicCircleCount = 0;
+ }
+
+ ///
+ /// Tests that the position on the path only increases when the monster entered one of the next areas,
+ /// although the areas overlap.
+ ///
+ [Test]
+ public async Task PathPositionAdvancesWithTheNextAreaAsync()
+ {
+ var (monster, intelligence) = await this.CreateMonsterAsync(true).ConfigureAwait(false);
+
+ await monster.MoveAsync(new Point(109, 105)).ConfigureAwait(false);
+ await intelligence.TickAsync().ConfigureAwait(false);
+ Assert.That(intelligence.PathPosition, Is.EqualTo(1), "The point is part of the first and the second area.");
+
+ await monster.MoveAsync(new Point(105, 105)).ConfigureAwait(false);
+ await intelligence.TickAsync().ConfigureAwait(false);
+ Assert.That(intelligence.PathPosition, Is.EqualTo(1), "The position never decreases.");
+ Assert.That(this._reachedMagicCircleCount, Is.Zero);
+ }
+
+ ///
+ /// Tests that a walking monster in the last area reaches the magic circle exactly once.
+ ///
+ [Test]
+ public async Task WalkingMonsterReachesMagicCircleOnceAsync()
+ {
+ var (monster, intelligence) = await this.CreateMonsterAsync(true).ConfigureAwait(false);
+
+ await monster.MoveAsync(new Point(125, 105)).ConfigureAwait(false);
+ await intelligence.TickAsync().ConfigureAwait(false);
+ await intelligence.TickAsync().ConfigureAwait(false);
+
+ Assert.That(intelligence.PathPosition, Is.EqualTo(Path.Count - 1));
+ Assert.That(this._reachedMagicCircleCount, Is.EqualTo(1));
+ }
+
+ ///
+ /// Tests that a monster which doesn't walk along the path, e.g. a larva of a chest,
+ /// doesn't count as reaching the magic circle when it stands next to it.
+ ///
+ [Test]
+ public async Task StationaryMonsterDoesntReachMagicCircleAsync()
+ {
+ var (monster, intelligence) = await this.CreateMonsterAsync(false).ConfigureAwait(false);
+
+ await monster.MoveAsync(new Point(125, 105)).ConfigureAwait(false);
+ await intelligence.TickAsync().ConfigureAwait(false);
+
+ Assert.That(this._reachedMagicCircleCount, Is.Zero);
+ }
+
+ private async Task<(Monster Monster, DoppelgangerMonsterIntelligence Intelligence)> CreateMonsterAsync(bool walksAlongPath)
+ {
+ var gameContext = GameContextTestHelper.CreateGameContext();
+ var map = await gameContext.GetMapAsync(0).ConfigureAwait(false);
+ var monsterDefinition = new MonsterDefinition { Id = Guid.NewGuid(), ObjectKind = NpcObjectKind.Monster, AttackDelay = TimeSpan.FromHours(1) };
+ monsterDefinition.Attributes.Add(new MonsterAttribute { AttributeDefinition = Stats.MaximumHealth, Value = 100 });
+ var spawnArea = new MonsterSpawnArea
+ {
+ MonsterDefinition = monsterDefinition,
+ GameMap = map!.Definition,
+ X1 = 101,
+ Y1 = 105,
+ X2 = 101,
+ Y2 = 105,
+ Quantity = 1,
+ };
+
+ var intelligence = new DoppelgangerMonsterIntelligence(
+ Path,
+ 0,
+ walksAlongPath,
+ false,
+ _ =>
+ {
+ this._reachedMagicCircleCount++;
+ return ValueTask.CompletedTask;
+ },
+ NullLogger.Instance);
+ var monster = new Monster(spawnArea, monsterDefinition, map, NullDropGenerator.Instance, intelligence, gameContext.PlugInManager, gameContext.PathFinderPool);
+ monster.Initialize();
+ await map.AddAsync(monster).ConfigureAwait(false);
+ return (monster, intelligence);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/DoppelgangerRemoteViewTests.cs b/tests/MUnique.OpenMU.Tests/DoppelgangerRemoteViewTests.cs
new file mode 100644
index 0000000000..4a2fffcd11
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/DoppelgangerRemoteViewTests.cs
@@ -0,0 +1,169 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.MiniGames;
+using MUnique.OpenMU.GameLogic.MiniGames.Doppelganger;
+using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames;
+using MUnique.OpenMU.GameServer.MessageHandler.MiniGames;
+using MUnique.OpenMU.GameServer.RemoteView.MiniGames;
+using MUnique.OpenMU.GameServer.RemoteView.NPC;
+using MUnique.OpenMU.Network.Packets.ServerToClient;
+using DoppelgangerResult = MUnique.OpenMU.GameLogic.MiniGames.Doppelganger.DoppelgangerResult;
+using DoppelgangerResultPacket = MUnique.OpenMU.Network.Packets.ServerToClient.DoppelgangerResult;
+
+///
+/// Tests the packets which are sent to the client during the doppelganger event.
+///
+[TestFixture]
+public class DoppelgangerRemoteViewTests
+{
+ ///
+ /// Tests the packets of the event view, including the clamping of the path positions
+ /// and the aligned reward experience of the result.
+ ///
+ [Test]
+ public async Task EventViewSerializesPacketsAsync()
+ {
+ var (player, output) = CastleSiegeRemoteViewTestHelper.CreatePlayer();
+ var view = new DoppelgangerEventViewPlugIn(player);
+
+ await view.ShowStateAsync(DoppelgangerState.Playing).ConfigureAwait(false);
+ await view.ShowMonsterPositionAsync(30).ConfigureAwait(false);
+ await view.ShowIceWalkerAsync(5).ConfigureAwait(false);
+ await view.HideIceWalkerAsync().ConfigureAwait(false);
+ await view.ShowMonsterGoalAsync(2, 3).ConfigureAwait(false);
+ await view.ShowResultAsync(DoppelgangerResult.MonstersReachedMagicCircle).ConfigureAwait(false);
+ await view.ShowPlayInfoAsync(TimeSpan.FromSeconds(125), []).ConfigureAwait(false);
+
+ var data = output.ToArray().AsMemory();
+ var playInfoLength = DoppelgangerPlayInfoRef.GetRequiredSize(0);
+ Assert.That(
+ data.Length,
+ Is.EqualTo(
+ DoppelgangerStateUpdate.Length
+ + DoppelgangerMonsterPosition.Length
+ + (2 * DoppelgangerIceWalkerState.Length)
+ + DoppelgangerMonsterGoal.Length
+ + DoppelgangerResultPacket.Length
+ + playInfoLength));
+
+ var offset = 0;
+ var state = (DoppelgangerStateUpdate)Next(DoppelgangerStateUpdate.Length);
+ Assert.That(state.State, Is.EqualTo(DoppelgangerStateUpdate.DoppelgangerState.Playing));
+
+ var monsterPosition = (DoppelgangerMonsterPosition)Next(DoppelgangerMonsterPosition.Length);
+ Assert.That(monsterPosition.Position, Is.EqualTo(IDoppelgangerEventViewPlugIn.MaximumPathPosition), "The position is clamped to the magic circle.");
+
+ var iceWalkerShown = (DoppelgangerIceWalkerState)Next(DoppelgangerIceWalkerState.Length);
+ Assert.That(iceWalkerShown.State, Is.EqualTo(DoppelgangerIceWalkerState.IceWalkerState.Appeared));
+ Assert.That(iceWalkerShown.Position, Is.EqualTo(5));
+
+ var iceWalkerHidden = (DoppelgangerIceWalkerState)Next(DoppelgangerIceWalkerState.Length);
+ Assert.That(iceWalkerHidden.State, Is.EqualTo(DoppelgangerIceWalkerState.IceWalkerState.Disappeared));
+
+ var goal = (DoppelgangerMonsterGoal)Next(DoppelgangerMonsterGoal.Length);
+ Assert.That(goal.GoalCount, Is.EqualTo(2));
+ Assert.That(goal.MaximumGoalCount, Is.EqualTo(3));
+
+ var resultData = Next(DoppelgangerResultPacket.Length);
+ var result = (DoppelgangerResultPacket)resultData;
+ Assert.That(result.Result, Is.EqualTo(DoppelgangerResultPacket.ResultType.MonstersReachedMagicCircle));
+ Assert.That(result.RewardExperience, Is.Zero);
+ Assert.That(resultData.Span[0], Is.EqualTo(0xC1));
+ Assert.That(resultData.Span[1], Is.EqualTo(12), "The client structure isn't packed, so the experience is aligned at offset 8.");
+
+ var playInfo = (DoppelgangerPlayInfo)Next(playInfoLength);
+ Assert.That(playInfo.RemainingSeconds, Is.EqualTo(125));
+ Assert.That(playInfo.PlayerCount, Is.Zero);
+
+ Memory Next(int length)
+ {
+ var packet = data.Slice(offset, length);
+ offset += length;
+ return packet;
+ }
+ }
+
+ ///
+ /// Tests that a failed entrance to the doppelganger event is shown with the event specific packet,
+ /// instead of throwing an exception like for mini games which aren't supported by the view.
+ ///
+ [Test]
+ public async Task EnterResultUsesDoppelgangerPacketAsync()
+ {
+ var (player, output) = CastleSiegeRemoteViewTestHelper.CreatePlayer();
+ var view = new ShowMiniGameEnterResultViewPlugIn(player);
+
+ await view.ShowResultAsync(MiniGameType.Doppelganger, EnterResult.PlayerKillerCantEnter).ConfigureAwait(false);
+
+ var packet = (DoppelgangerEnterResult)output.ToArray().AsMemory();
+ Assert.That(packet.Result, Is.EqualTo(DoppelgangerEnterResult.EnterResult.PlayerKiller));
+ }
+
+ ///
+ /// Tests the countdowns of the event, which use the values the client expects on the doppelganger maps.
+ ///
+ [Test]
+ public async Task CountdownsUseDoppelgangerValuesAsync()
+ {
+ var (player, output) = CastleSiegeRemoteViewTestHelper.CreatePlayer();
+ var miniGameStateView = new UpdateMiniGameStateViewViewPlugIn(player);
+ var eventView = new DoppelgangerEventViewPlugIn(player);
+
+ await miniGameStateView.UpdateStateAsync(MiniGameType.Doppelganger, MiniGameState.Closed).ConfigureAwait(false);
+ await eventView.ShowIceWalkerCountdownAsync().ConfigureAwait(false);
+ await miniGameStateView.UpdateStateAsync(MiniGameType.Doppelganger, MiniGameState.Ended).ConfigureAwait(false);
+
+ var data = output.ToArray().AsMemory();
+ Assert.That(data.Length, Is.EqualTo(3 * UpdateMiniGameState.Length));
+ var states = Enumerable.Range(0, 3)
+ .Select(i => ((UpdateMiniGameState)data.Slice(i * UpdateMiniGameState.Length, UpdateMiniGameState.Length)).State)
+ .ToList();
+ Assert.That(states, Is.EqualTo(new[]
+ {
+ UpdateMiniGameState.MiniGameTypeState.DoppelgangerStarting,
+ UpdateMiniGameState.MiniGameTypeState.DoppelgangerIceWalkerCountdown,
+ UpdateMiniGameState.MiniGameTypeState.DoppelgangerEnding,
+ }));
+ }
+
+ ///
+ /// Tests that the entrance window of Lugard is opened with zero minutes until the entrance opens,
+ /// so the client enables the enter button.
+ ///
+ [Test]
+ public async Task LugardWindowAllowsEntranceAsync()
+ {
+ var (player, output) = CastleSiegeRemoteViewTestHelper.CreatePlayer();
+ var view = new OpenNpcWindowPlugIn(player);
+
+ await view.OpenNpcWindowAsync(NpcWindow.LugardDoppelgangerEntry).ConfigureAwait(false);
+
+ var data = output.ToArray();
+ Assert.That(data, Has.Length.EqualTo(NpcWindowResponse.Length));
+ Assert.That(((NpcWindowResponse)data.AsMemory()).Window, Is.EqualTo(NpcWindowResponse.NpcWindow.LugardDoppelgangerEntry));
+ Assert.That(data[4], Is.Zero, "The byte after the window contains the minutes until the entrance opens.");
+ }
+
+ ///
+ /// Tests that the handler of the enter request belongs to the right sub code and ignores truncated packets.
+ ///
+ [Test]
+ public async Task EnterHandlerIgnoresTruncatedPacketAsync()
+ {
+ var (player, output) = CastleSiegeRemoteViewTestHelper.CreatePlayer();
+ var handler = new DoppelgangerEnterRequestHandlerPlugIn();
+
+ Assert.That(handler.Key, Is.EqualTo(0x0E));
+
+ await handler.HandlePacketAsync(player, new byte[] { 0xC1, 0x04, 0xBF, 0x0E }).ConfigureAwait(false);
+
+ Assert.That(output.Length, Is.Zero);
+ Assert.That(player.CurrentMiniGame, Is.Null);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/EnterDoppelgangerActionTest.cs b/tests/MUnique.OpenMU.Tests/EnterDoppelgangerActionTest.cs
new file mode 100644
index 0000000000..94dbb6ba23
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/EnterDoppelgangerActionTest.cs
@@ -0,0 +1,58 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames;
+
+///
+/// Tests for the ticket search of the .
+///
+[TestFixture]
+public class EnterDoppelgangerActionTest
+{
+ private const byte UndefinedTicketSlot = 0xFF;
+
+ ///
+ /// Tests that the mirror of dimensions is preferred over the free ticket, when no slot is given.
+ ///
+ [Test]
+ public async Task MirrorIsPreferredAsync()
+ {
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var freeTicket = await AddItemAsync(player, 12, 13, 125, 1).ConfigureAwait(false);
+ var mirror = await AddItemAsync(player, 13, 14, 111, 1).ConfigureAwait(false);
+
+ Assert.That(EnterDoppelgangerAction.FindTicket(player, UndefinedTicketSlot), Is.SameAs(mirror));
+ Assert.That(EnterDoppelgangerAction.FindTicket(player, 12), Is.SameAs(freeTicket), "The given slot is used, if it contains a ticket.");
+ }
+
+ ///
+ /// Tests that a used up ticket and other items aren't accepted.
+ ///
+ [Test]
+ public async Task UsedUpTicketIsIgnoredAsync()
+ {
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ await AddItemAsync(player, 12, 13, 125, 0).ConfigureAwait(false);
+ await AddItemAsync(player, 13, 14, 110, 5).ConfigureAwait(false);
+
+ Assert.That(EnterDoppelgangerAction.FindTicket(player, UndefinedTicketSlot), Is.Null);
+ Assert.That(EnterDoppelgangerAction.FindTicket(player, 13), Is.Null, "A sign of dimensions isn't a ticket.");
+ }
+
+ private static async Task- AddItemAsync(GameLogic.Player player, byte slot, byte group, short number, byte durability)
+ {
+ var item = new Item
+ {
+ Definition = new ItemDefinition { Group = group, Number = number, Width = 1, Height = 1, Durability = 1 },
+ Durability = durability,
+ ItemSlot = slot,
+ };
+ await player.Inventory!.AddItemAsync(slot, item).ConfigureAwait(false);
+ return item;
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/MonsterInstanceMultiplierTest.cs b/tests/MUnique.OpenMU.Tests/MonsterInstanceMultiplierTest.cs
new file mode 100644
index 0000000000..2e508f3d91
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/MonsterInstanceMultiplierTest.cs
@@ -0,0 +1,74 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using Moq;
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.NPC;
+using MonsterAttribute = MUnique.OpenMU.Persistence.BasicModel.MonsterAttribute;
+using MonsterDefinition = MUnique.OpenMU.Persistence.BasicModel.MonsterDefinition;
+
+///
+/// Tests that multipliers can be applied to the attributes of a single monster instance,
+/// like the doppelganger event does to make its monsters stronger.
+///
+[TestFixture]
+public class MonsterInstanceMultiplierTest
+{
+ ///
+ /// Tests that a multiplier which is added before the monster is initialized applies to
+ /// its maximum and initial health and to its defense, but not to other instances of the same definition.
+ ///
+ [Test]
+ public async Task MultiplierAppliesToInstanceAsync()
+ {
+ var gameContext = GameContextTestHelper.CreateGameContext();
+ // A unique id, because the attributes of monster definitions are cached by their id.
+ var monsterDefinition = new MonsterDefinition { Id = Guid.NewGuid(), ObjectKind = NpcObjectKind.Monster };
+ monsterDefinition.Attributes.Add(new MonsterAttribute { AttributeDefinition = Stats.MaximumHealth, Value = 1000 });
+ monsterDefinition.Attributes.Add(new MonsterAttribute { AttributeDefinition = Stats.DefenseBase, Value = 100 });
+
+ var scaledMonster = await CreateMonsterAsync(gameContext, monsterDefinition).ConfigureAwait(false);
+ scaledMonster.Attributes.AddElement(new SimpleElement(3, AggregateType.Multiplicate), Stats.MaximumHealth);
+ scaledMonster.Attributes.AddElement(new SimpleElement(2, AggregateType.Multiplicate), Stats.DefenseBase);
+ scaledMonster.Initialize();
+
+ var normalMonster = await CreateMonsterAsync(gameContext, monsterDefinition).ConfigureAwait(false);
+ normalMonster.Initialize();
+
+ Assert.That(scaledMonster.Attributes[Stats.MaximumHealth], Is.EqualTo(3000));
+ Assert.That(scaledMonster.Health, Is.EqualTo(3000));
+ Assert.That(scaledMonster.Attributes[Stats.DefensePvm], Is.EqualTo(200));
+ Assert.That(normalMonster.Attributes[Stats.MaximumHealth], Is.EqualTo(1000));
+ Assert.That(normalMonster.Health, Is.EqualTo(1000));
+ }
+
+ private static async ValueTask CreateMonsterAsync(IGameContext gameContext, MonsterDefinition monsterDefinition)
+ {
+ var map = await gameContext.GetMapAsync(0).ConfigureAwait(false);
+ var spawnArea = new MonsterSpawnArea
+ {
+ MonsterDefinition = monsterDefinition,
+ GameMap = map!.Definition,
+ X1 = 100,
+ Y1 = 100,
+ X2 = 100,
+ Y2 = 100,
+ Quantity = 1,
+ };
+
+ return new Monster(
+ spawnArea,
+ monsterDefinition,
+ map,
+ NullDropGenerator.Instance,
+ new Mock().Object,
+ gameContext.PlugInManager,
+ gameContext.PathFinderPool);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/SignOfDimensionsStackedPlugInTest.cs b/tests/MUnique.OpenMU.Tests/SignOfDimensionsStackedPlugInTest.cs
new file mode 100644
index 0000000000..2368e4bc52
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/SignOfDimensionsStackedPlugInTest.cs
@@ -0,0 +1,60 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic.PlugIns;
+
+///
+/// Tests for the .
+///
+[TestFixture]
+public class SignOfDimensionsStackedPlugInTest
+{
+ private readonly SignOfDimensionsStackedPlugIn _plugIn = new();
+
+ ///
+ /// Tests that a full stack of five signs of dimensions transforms into a mirror of dimensions.
+ ///
+ [Test]
+ public async Task FullStackTransformsIntoMirrorAsync()
+ {
+ var (player, sign, mirror) = await CreatePlayerWithSignAsync(5).ConfigureAwait(false);
+
+ await this._plugIn.ItemStackedAsync(player, new Item(), sign).ConfigureAwait(false);
+
+ Assert.That(sign.Definition, Is.SameAs(mirror));
+ Assert.That(sign.Durability, Is.EqualTo(1));
+ }
+
+ ///
+ /// Tests that an incomplete stack of signs of dimensions stays as it is.
+ ///
+ [Test]
+ public async Task IncompleteStackStaysAsync()
+ {
+ var (player, sign, _) = await CreatePlayerWithSignAsync(4).ConfigureAwait(false);
+ var signDefinition = sign.Definition;
+
+ await this._plugIn.ItemStackedAsync(player, new Item(), sign).ConfigureAwait(false);
+
+ Assert.That(sign.Definition, Is.SameAs(signDefinition));
+ Assert.That(sign.Durability, Is.EqualTo(4));
+ }
+
+ private static async Task<(GameLogic.Player Player, Item Sign, ItemDefinition Mirror)> CreatePlayerWithSignAsync(byte stackedSigns)
+ {
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var signDefinition = new ItemDefinition { Group = 14, Number = 110, Width = 1, Height = 1, Durability = 5 };
+ var mirrorDefinition = new ItemDefinition { Group = 14, Number = 111, Width = 1, Height = 1, Durability = 1 };
+ player.GameContext.Configuration.Items.Add(signDefinition);
+ player.GameContext.Configuration.Items.Add(mirrorDefinition);
+
+ var sign = new Item { Definition = signDefinition, Durability = stackedSigns, ItemSlot = 12 };
+ await player.Inventory!.AddItemAsync(12, sign).ConfigureAwait(false);
+ return (player, sign, mirrorDefinition);
+ }
+}