From 20f9a01c9166f2dd650c5b0f6aaebe64ccc2efc5 Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:03:11 -0300 Subject: [PATCH 01/18] feat(doppelganger): add server-to-client packets 0xBF 0x0E-0x14 Defines the doppelganger event packets as expected by the season 6 client: enter result, monster position, state, ice walker state, play info (timer and party positions), result and monster goal count. The client structures are not packed, so the reward experience of the result packet is aligned at offset 8 and the packet is 12 bytes long. Co-Authored-By: Claude Opus 5.5 --- ...BF-0E-DoppelgangerEnterResult_by-server.md | 31 + ...F-DoppelgangerMonsterPosition_by-server.md | 19 + ...BF-10-DoppelgangerStateUpdate_by-server.md | 30 + ...11-DoppelgangerIceWalkerState_by-server.md | 29 + ...C1-BF-12-DoppelgangerPlayInfo_by-server.md | 33 + .../C1-BF-13-DoppelgangerResult_by-server.md | 30 + ...BF-14-DoppelgangerMonsterGoal_by-server.md | 20 + docs/Packets/ServerToClient.md | 7 + .../ServerToClient/ConnectionExtensions.cs | 174 ++++ .../ServerToClient/ServerToClientPackets.cs | 789 ++++++++++++++++++ .../ServerToClient/ServerToClientPackets.xml | 282 +++++++ .../ServerToClientPacketsRef.cs | 695 +++++++++++++++ .../ServerToClientPacketTests.cs | 138 +++ 13 files changed, 2277 insertions(+) create mode 100644 docs/Packets/C1-BF-0E-DoppelgangerEnterResult_by-server.md create mode 100644 docs/Packets/C1-BF-0F-DoppelgangerMonsterPosition_by-server.md create mode 100644 docs/Packets/C1-BF-10-DoppelgangerStateUpdate_by-server.md create mode 100644 docs/Packets/C1-BF-11-DoppelgangerIceWalkerState_by-server.md create mode 100644 docs/Packets/C1-BF-12-DoppelgangerPlayInfo_by-server.md create mode 100644 docs/Packets/C1-BF-13-DoppelgangerResult_by-server.md create mode 100644 docs/Packets/C1-BF-14-DoppelgangerMonsterGoal_by-server.md 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 000000000..bc7a48209 --- /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 000000000..9a946b2a4 --- /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 000000000..eae74f682 --- /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 000000000..c874edd71 --- /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 000000000..0a94507d0 --- /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 000000000..f83a1a2f3 --- /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; It is not shown by the client. The field is aligned to 4 bytes, because the original 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 000000000..8d70f5260 --- /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 7fbc988d0..73d5b31f9 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/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs index 7d50718b0..85691cbcb 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. + /// It is not shown by the client. The field is aligned to 4 bytes, because the original 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 7f6fee195..0d6e118e1 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 it is not shown by the client. The field is aligned to 4 bytes, because the original 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. diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml index a2ddfb957..46b39ffd3 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 + It is not shown by the client. The field is aligned to 4 bytes, because the original 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 diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs index fff09c959..6cbd6823f 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 it is not shown by the client. The field is aligned to 4 bytes, because the original 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/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs b/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs index 0aa5975ef..2d2fc5a2a 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. /// From 9bbdaf2f183dba5faf569c9b2d536d0e3579c28d Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:03:12 -0300 Subject: [PATCH 02/18] feat(doppelganger): add event view plugin Adds IDoppelgangerEventViewPlugIn with its default implementation, which sends the doppelganger packets (0xBF 0x0F-0x14). The play info packet has a variable length, so it's written directly like the chain lightning packet. The enter result of the doppelganger is now handled by ShowMiniGameEnterResultViewPlugIn, instead of throwing an ArgumentOutOfRangeException. Co-Authored-By: Claude Opus 5.5 --- .../Doppelganger/DoppelgangerResult.cs | 26 +++++ .../Doppelganger/DoppelgangerState.cs | 31 +++++ .../IDoppelgangerEventViewPlugIn.cs | 69 +++++++++++ .../Properties/PlugInResources.Designer.cs | 18 +++ .../Properties/PlugInResources.resx | 6 + .../MiniGames/DoppelgangerEventViewPlugIn.cs | 107 ++++++++++++++++++ .../RemoteView/MiniGames/Extensions.cs | 19 +++- .../ShowMiniGameEnterResultViewPlugIn.cs | 3 + 8 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerResult.cs create mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerState.cs create mode 100644 src/GameLogic/MiniGames/Doppelganger/IDoppelgangerEventViewPlugIn.cs create mode 100644 src/GameServer/RemoteView/MiniGames/DoppelgangerEventViewPlugIn.cs diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerResult.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerResult.cs new file mode 100644 index 000000000..eea6a6ea8 --- /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 000000000..9dacbf590 --- /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 000000000..736086e86 --- /dev/null +++ b/src/GameLogic/MiniGames/Doppelganger/IDoppelgangerEventViewPlugIn.cs @@ -0,0 +1,69 @@ +// +// 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 +{ + /// + /// The maximum position index on the path, which is the position of the magic circle. + /// + const 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 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/GameServer/Properties/PlugInResources.Designer.cs b/src/GameServer/Properties/PlugInResources.Designer.cs index 7e767d042..171543605 100644 --- a/src/GameServer/Properties/PlugInResources.Designer.cs +++ b/src/GameServer/Properties/PlugInResources.Designer.cs @@ -3192,6 +3192,24 @@ public static string KanturuEnterRequestHandlerPlugIn_Name { } } + /// + /// 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 e9361de3f..d4b1b927c 100644 --- a/src/GameServer/Properties/PlugInResources.resx +++ b/src/GameServer/Properties/PlugInResources.resx @@ -1335,6 +1335,12 @@ The default implementation of the IChaosCastleStateViewPlugin which is forwarding everything to the game client with specific data packets. + + 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 000000000..69b7afccf --- /dev/null +++ b/src/GameServer/RemoteView/MiniGames/DoppelgangerEventViewPlugIn.cs @@ -0,0 +1,107 @@ +// +// 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 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 9ce81ee8a..684ff9ae4 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 702a53909..013f86cec 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: From 42760378e6a210aefda01df109e279ebf5f8e921 Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:03:12 -0300 Subject: [PATCH 03/18] feat(doppelganger): add the event context skeleton DoppelgangerContext runs the event on top of the mini game lifecycle: - shows the event frame when the game starts and sends the remaining time and the path positions of the players every second, - counts the monsters which reached the magic circle and ends the event when too many of them reached it, - fails the event for a player which dies or leaves the map, - sends the result to the remaining players when the game ends. The path of each map consists of 23 areas, taken from the original season 6 server data. The countdowns of the mini game (0x92) got the values of the doppelganger, and the MiniGameManager creates the new context. The monster waves and the movement along the path follow in a later step. Co-Authored-By: Claude Opus 5.5 --- .../C1-92-UpdateMiniGameState_by-server.md | 6 +- .../Doppelganger/DoppelgangerContext.cs | 208 ++++++++++++++++++ .../DoppelgangerEventDefinition.cs | 204 +++++++++++++++++ .../Doppelganger/DoppelgangerPath.cs | 22 ++ .../Doppelganger/DoppelgangerPathArea.cs | 38 ++++ src/GameLogic/MiniGames/MiniGameManager.cs | 4 + .../UpdateMiniGameStateViewViewPlugIn.cs | 6 + .../ServerToClient/ServerToClientPackets.cs | 20 ++ .../ServerToClient/ServerToClientPackets.xml | 20 ++ .../DoppelgangerEventDefinitionTest.cs | 67 ++++++ 10 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs create mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs create mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerPath.cs create mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerPathArea.cs create mode 100644 tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs diff --git a/docs/Packets/C1-92-UpdateMiniGameState_by-server.md b/docs/Packets/C1-92-UpdateMiniGameState_by-server.md index 383a31e77..375b2b2b4 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/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs new file mode 100644 index 000000000..a20b2c093 --- /dev/null +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs @@ -0,0 +1,208 @@ +// +// 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.GameLogic.Views; + +/// +/// The context of a doppelganger event game. +/// +/// +/// The players defend a magic circle against monsters which walk along a path towards it. +/// 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 +{ + private readonly DoppelgangerEventDefinition _definition; + + /// + /// The players for which the event already ended with a result, e.g. because they died. + /// + private readonly ConcurrentDictionary _playerResults = new(); + + private DateTime _gameEndsAtUtc; + private int _goalCount; + + /// + /// 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._definition = DoppelgangerEventDefinition.CreateDefault(); + } + + /// + /// 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() + { + var goalCount = Interlocked.Increment(ref this._goalCount); + await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => + p.ShowMonsterGoalAsync(goalCount, 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(); + } + } + + /// + protected override async ValueTask OnGameStartAsync(ICollection players) + { + await base.OnGameStartAsync(players).ConfigureAwait(false); + + this._gameEndsAtUtc = DateTime.UtcNow.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); + } + + /// +#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) + { + 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); + } + + await base.GameEndedAsync(finishers).ConfigureAwait(false); + } + + /// + /// 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); + } + 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); + } +} diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs new file mode 100644 index 000000000..69aa4b128 --- /dev/null +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs @@ -0,0 +1,204 @@ +// +// 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; + +/// +/// Describes the run of the doppelganger event. +/// +public class DoppelgangerEventDefinition +{ + /// + /// 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 paths of the monsters, one for each event map. + /// + public IList Paths { get; set; } = new List(); + + /// + /// Creates the definition of the original season 6 event. + /// + /// The created definition. + public static DoppelgangerEventDefinition CreateDefault() + { + return new DoppelgangerEventDefinition + { + Paths = CreateDefaultPaths(), + }; + } + + /// + /// 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 original season 6 event. 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 + [ + // Doppelganger 1 (Ice), map 65 + new DoppelgangerPath + { + MapNumber = 65, + 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), + ], + }, + // Doppelganger 2 (Fire), map 66 + new DoppelgangerPath + { + MapNumber = 66, + 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), + ], + }, + // Doppelganger 3 (Water), map 67 + new DoppelgangerPath + { + MapNumber = 67, + 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), + ], + }, + // Doppelganger 4 (Ground), map 68 + new DoppelgangerPath + { + MapNumber = 68, + 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), + ], + }, + ]; + } +} diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPath.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPath.cs new file mode 100644 index 000000000..6a14d5a71 --- /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 000000000..1a4eac7c9 --- /dev/null +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPathArea.cs @@ -0,0 +1,38 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.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. As in the original server, the lower bounds are exclusive +/// and the upper bounds are inclusive. +/// +/// The lower x coordinate (exclusive). +/// The lower y coordinate (exclusive). +/// The upper x coordinate (inclusive). +/// The upper y coordinate (inclusive). +public record DoppelgangerPathArea(byte X1, byte Y1, byte X2, byte Y2) +{ + /// + /// Gets the center of the area, which is the target of the monsters walking to this area. + /// + public Point Center => new((byte)(this.X1 + ((this.X2 - this.X1) / 2)), (byte)(this.Y1 + ((this.Y2 - this.Y1) / 2))); + + /// + /// 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; + } +} diff --git a/src/GameLogic/MiniGames/MiniGameManager.cs b/src/GameLogic/MiniGames/MiniGameManager.cs index 4fc7b103a..75e7a778e 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/GameServer/RemoteView/MiniGames/UpdateMiniGameStateViewViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/UpdateMiniGameStateViewViewPlugIn.cs index 1436e2672..66a6a0e45 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/Network/Packets/ServerToClient/ServerToClientPackets.cs b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs index 0d6e118e1..c7d5877ad 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs @@ -30587,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 46b39ffd3..3877859fa 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -10994,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/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs new file mode 100644 index 000000000..111273c11 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs @@ -0,0 +1,67 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; +using MUnique.OpenMU.Pathfinding; +using NUnit.Framework; + +/// +/// Tests for the . +/// +[TestFixture] +public class DoppelgangerEventDefinitionTest +{ + private readonly DoppelgangerEventDefinition _definition = DoppelgangerEventDefinition.CreateDefault(); + + /// + /// 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, + /// as in the original server. + /// + [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(area.Center, Is.EqualTo(new Point(12, 22))); + } + + /// + /// 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); + } +} From fcc78665b4c59c90df9daedfec8c6fb21f9961ac Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:03:12 -0300 Subject: [PATCH 04/18] feat(doppelganger): enter the event through the NPC Lugard - Lugard opens the doppelganger entrance window. The byte after the window (minutes until the entrance opens) is sent as 0, instead of leaving it uninitialized. - The DoppelgangerEnterRequest (0xBF 0x0E) is handled. The first player of a party enters a random event map, the other members follow into the same instance. The Mirror of Dimensions (14,111) is consumed as ticket. - Four mini game definitions, one per event map, with one instance per party. - Players who die inside the event maps respawn at Elvenland. - An update plug-in adds all of this to existing season 6 databases. Co-Authored-By: Claude Opus 5.5 --- .../MiniGames/EnterDoppelgangerAction.cs | 42 +++++++++ .../DoppelgangerEnterRequestHandlerPlugIn.cs | 52 +++++++++++ .../Properties/PlugInResources.Designer.cs | 18 ++++ .../Properties/PlugInResources.resx | 6 ++ .../RemoteView/NPC/OpenNpcWindowPlugIn.cs | 32 +++++++ .../AddDoppelgangerDataUpdatePlugIn.cs | 83 +++++++++++++++++ .../Initialization/Updates/UpdateVersion.cs | 5 + .../Events/DoppelgangerInitializer.cs | 61 +++++++++++++ .../GameConfigurationInitializer.cs | 1 + .../Items/EventTicketItems.cs | 11 +++ .../VersionSeasonSix/Maps/Doppelgaenger1.cs | 6 ++ .../VersionSeasonSix/Maps/Doppelgaenger2.cs | 6 ++ .../VersionSeasonSix/Maps/Doppelgaenger3.cs | 6 ++ .../VersionSeasonSix/Maps/Doppelgaenger4.cs | 6 ++ .../VersionSeasonSix/NpcInitialization.cs | 1 + .../DoppelgangerDataTest.cs | 91 +++++++++++++++++++ 16 files changed, 427 insertions(+) create mode 100644 src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs create mode 100644 src/GameServer/MessageHandler/MiniGames/DoppelgangerEnterRequestHandlerPlugIn.cs create mode 100644 src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs create mode 100644 src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerInitializer.cs create mode 100644 tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs diff --git a/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs b/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs new file mode 100644 index 000000000..ddd555931 --- /dev/null +++ b/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs @@ -0,0 +1,42 @@ +// +// 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; + +/// +/// 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. +/// +public class EnterDoppelgangerAction +{ + 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) + { + 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); + } +} diff --git a/src/GameServer/MessageHandler/MiniGames/DoppelgangerEnterRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/MiniGames/DoppelgangerEnterRequestHandlerPlugIn.cs new file mode 100644 index 000000000..e8cc346d1 --- /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 171543605..c95bd29cf 100644 --- a/src/GameServer/Properties/PlugInResources.Designer.cs +++ b/src/GameServer/Properties/PlugInResources.Designer.cs @@ -3192,6 +3192,24 @@ 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.. /// diff --git a/src/GameServer/Properties/PlugInResources.resx b/src/GameServer/Properties/PlugInResources.resx index d4b1b927c..5b4e5b0a4 100644 --- a/src/GameServer/Properties/PlugInResources.resx +++ b/src/GameServer/Properties/PlugInResources.resx @@ -1335,6 +1335,12 @@ 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 diff --git a/src/GameServer/RemoteView/NPC/OpenNpcWindowPlugIn.cs b/src/GameServer/RemoteView/NPC/OpenNpcWindowPlugIn.cs index 2dbb1ad47..4d42964ef 100644 --- a/src/GameServer/RemoteView/NPC/OpenNpcWindowPlugIn.cs +++ b/src/GameServer/RemoteView/NPC/OpenNpcWindowPlugIn.cs @@ -37,12 +37,44 @@ 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); } } + /// + /// 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); + } + private static NpcWindowResponse.NpcWindow Convert(NpcWindow window) { return window switch diff --git a/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs new file mode 100644 index 000000000..20a5cb23c --- /dev/null +++ b/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs @@ -0,0 +1,83 @@ +// +// 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 as entrance ticket and the s, +/// opens the entrance window when talking to Lugard, and lets players who die inside the +/// event maps respawn at Elvenland. +/// +[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 (!gameConfiguration.Items.Any(item => item is { Group: 14, Number: 111 })) + { + new EventTicketItems(context, gameConfiguration).CreateDoppelgangerItems(); + } + + 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 (!gameConfiguration.MiniGameDefinitions.Any(definition => definition.Type == MiniGameType.Doppelganger)) + { + new DoppelgangerInitializer(context, gameConfiguration).Initialize(); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Persistence/Initialization/Updates/UpdateVersion.cs b/src/Persistence/Initialization/Updates/UpdateVersion.cs index cab7f0678..a94614854 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 000000000..3138b64fb --- /dev/null +++ b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerInitializer.cs @@ -0,0 +1,61 @@ +// +// 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. +/// +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() + { + var ticket = this.GameConfiguration.Items.Single(item => item is { Group: 14, Number: 111 }); + 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 = ticket; + doppelganger.GameLevel = (byte)gameLevel; + doppelganger.MapCreationPolicy = MiniGameMapCreationPolicy.OnePerParty; + doppelganger.SaveRankingStatistics = false; + doppelganger.AllowParty = true; + } + } +} diff --git a/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs index ee29fe432..a7a0ad04a 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/Items/EventTicketItems.cs b/src/Persistence/Initialization/VersionSeasonSix/Items/EventTicketItems.cs index 4920a197c..32230c381 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Items/EventTicketItems.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Items/EventTicketItems.cs @@ -39,6 +39,9 @@ public override void Initialize() this.CreateEventItem(50, 13, 1, 2, "Illusion Sorcerer Covenant", false, 6, 70, 76, 82, 88, 94, 100); this.CreateEventItem(51, 13, 2, 2, "Scroll of Blood", false, 6); + // Doppelganger: + this.CreateDoppelgangerItems(); + // Devil Square: this.CreateEventItem(17, 14, 1, 1, "Devil's Eye", false, 7, 2, 36, 47, 60, 70, 80, 90); this.CreateEventItem(18, 14, 1, 1, "Devil's Key", false, 7, 2, 36, 47, 60, 70, 80, 90); @@ -57,6 +60,14 @@ public override void Initialize() this.CreateEventItem(109, 14, 1, 1, "Complete Secromicon", false); } + /// + /// Creates the items of the doppelganger event. + /// + internal void CreateDoppelgangerItems() + { + this.CreateEventItem(111, 14, 1, 1, "Mirror of Dimensions", false); + } + 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 23f51f7d4..f8c3aaa45 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 6ddae0580..19f047ced 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 3d5e08080..9cf5a14f9 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 aa9effbc6..3e9912ac3 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 e1c16ac59..68276ebf3 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.Persistence.Initialization.Tests/DoppelgangerDataTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs new file mode 100644 index 000000000..357fe26be --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs @@ -0,0 +1,91 @@ +// +// 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.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 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.Monsters.Single(monster => monster.Number == LugardNumber).NpcWindow = NpcWindow.Undefined; + foreach (var map in gameConfiguration.Maps.Where(map => EventMapNumbers.Contains(map.Number))) + { + map.SafezoneMap = map; + } + + var update = new AddDoppelgangerDataUpdatePlugIn(); + await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + await update.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) + { + var ticket = gameConfiguration.Items.Single(item => item is { Group: 14, Number: 111 }); + 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 == ticket)); + 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)); + } +} From ed8587251bf10ad113b12b3f82a492dacd57c466 Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:03:12 -0300 Subject: [PATCH 05/18] feat(doppelganger): monsters walk along the path to the magic circle - Adds the monsters 529-539 with the base values of the original season 6 server data. The class clones use the skills of their class. - Herds spawn every 3 seconds at the start of the path. Their size grows with the game time and the number of players. Additional butchers and dark lord clones appear after 1, 4 and 7 minutes. - DoppelgangerMonsterIntelligence lets the monsters walk from area to area towards the magic circle. They only attack players in their attack range, so they don't leave the path, and they keep walking when no player observes them. When a monster reaches the magic circle, it's removed and counted as goal. - The ice walkers appear at a random position of the path after almost six minutes. They disappear when they aren't killed within a minute. The client shows their position and the countdown. - The client shows the position of the most advanced monster. - The update plug-in also adds the monsters to existing databases. Co-Authored-By: Claude Opus 5.5 --- .../Doppelganger/DoppelgangerContext.cs | 262 +++++++++++++++++- .../DoppelgangerEventDefinition.cs | 91 ++++++ .../Doppelganger/DoppelgangerHerdSize.cs | 21 ++ .../DoppelgangerMonsterIntelligence.cs | 260 +++++++++++++++++ .../Doppelganger/DoppelgangerMonsterSpawn.cs | 22 ++ .../IDoppelgangerEventViewPlugIn.cs | 5 + .../MiniGames/DoppelgangerEventViewPlugIn.cs | 6 + .../AddDoppelgangerDataUpdatePlugIn.cs | 7 +- .../Events/DoppelgangerInitializer.cs | 5 + .../Events/DoppelgangerMonsters.cs | 106 +++++++ .../DoppelgangerDataTest.cs | 31 +++ .../DoppelgangerEventDefinitionTest.cs | 16 ++ 12 files changed, 830 insertions(+), 2 deletions(-) create mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerHerdSize.cs create mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs create mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterSpawn.cs create mode 100644 src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs index a20b2c093..b58b8c0aa 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; using System.Collections.Concurrent; using System.Threading; +using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Views; /// @@ -13,6 +14,9 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; /// /// /// 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 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. @@ -20,15 +24,31 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; /// public sealed class DoppelgangerContext : MiniGameContext { + /// + /// The duration of the countdown which the client shows for the ice walker mission. + /// + private static readonly TimeSpan IceWalkerCountdownDuration = TimeSpan.FromSeconds(30); + + 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(); + + private DateTime _gameStartedUtc; private DateTime _gameEndsAtUtc; private int _goalCount; + private int _lastShownMonsterPosition = -1; /// /// Initializes a new instance of the class. @@ -44,7 +64,10 @@ public DoppelgangerContext( IMapInitializer mapInitializer) : base(key, definition, gameContext, mapInitializer) { + this._gameContext = gameContext; this._definition = DoppelgangerEventDefinition.CreateDefault(); + var mapNumber = (short)this.Map.MapId; + this._path = this._definition.Paths.FirstOrDefault(path => path.MapNumber == mapNumber)?.Areas ?? []; } /// @@ -68,6 +91,11 @@ protected override TimeSpan RemainingTime /// public async ValueTask RegisterMonsterReachedMagicCircleAsync() { + if (this.State != MiniGameState.Playing) + { + return; + } + var goalCount = Interlocked.Increment(ref this._goalCount); await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowMonsterGoalAsync(goalCount, this._definition.MaximumGoalCount)).AsTask()).ConfigureAwait(false); @@ -84,7 +112,8 @@ protected override async ValueTask OnGameStartAsync(ICollection players) { await base.OnGameStartAsync(players).ConfigureAwait(false); - this._gameEndsAtUtc = DateTime.UtcNow.Add(this.Definition.GameDuration); + this._gameStartedUtc = DateTime.UtcNow; + this._gameEndsAtUtc = this._gameStartedUtc.Add(this.Definition.GameDuration); await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(async p => { await p.ShowStateAsync(DoppelgangerState.Playing).ConfigureAwait(false); @@ -92,6 +121,34 @@ await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync 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._iceWalkers.TryRemove(monster, out _) && this._iceWalkers.IsEmpty) + { + this.Logger.LogDebug("{context}: All ice walkers were killed.", this); + 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); + } } /// @@ -129,6 +186,9 @@ protected override async ValueTask OnObjectRemovedFromMapAsync((GameMap Map, ILo /// protected override async ValueTask GameEndedAsync(ICollection finishers) { + this._pathMonsters.Clear(); + this._iceWalkers.Clear(); + var result = this.IsDefenseFailed ? DoppelgangerResult.MonstersReachedMagicCircle : DoppelgangerResult.Success; foreach (var player in finishers) { @@ -172,6 +232,7 @@ private async Task RunPlayInfoLoopAsync(CancellationToken cancellationToken) do { await this.ShowPlayInfoAsync().ConfigureAwait(false); + await this.ShowMonsterPositionAsync().ConfigureAwait(false); } while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)); } @@ -205,4 +266,203 @@ await this.ForEachPlayerAsync(player => 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 monsterNumber in pendingSpawns[0].MonsterNumbers) + { + await this.SpawnMonsterAsync(monsterNumber, 0, 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.HerdMonsterNumbers.Count == 0) + { + return; + } + + var count = this._definition.GetHerdBaseCount(elapsed) + Math.Max(0, this.PlayerCount - 1); + for (var i = 0; i < count; i++) + { + var monsterNumber = this._definition.HerdMonsterNumbers[Rand.NextInt(0, this._definition.HerdMonsterNumbers.Count)]; + var attacksFirst = this._definition.AlwaysAttackingMonsterNumbers.Contains(monsterNumber) + || Rand.NextRandomBool(this._definition.AttackFirstChance); + await this.SpawnMonsterAsync(monsterNumber, 0, true, attacksFirst).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.IceWalkerNumber, 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.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.", this); + foreach (var iceWalker in remainingIceWalkers) + { + await this.RemoveMonsterAsync(iceWalker).ConfigureAwait(false); + } + + await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.HideIceWalkerAsync()).AsTask()).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // The game ended. + } + catch (Exception ex) + { + this.Logger.LogError(ex, "{context}: Unexpected error in the ice walker mission.", this); + } + } + + private async ValueTask SpawnMonsterAsync(short monsterNumber, int pathPosition, bool walksAlongPath, bool attacksFirst) + { + if (this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == monsterNumber) is not { } monsterDefinition) + { + this.Logger.LogWarning("{context}: Monster definition {monsterNumber} not found.", this, monsterNumber); + return null; + } + + var area = this._path[pathPosition]; + var spawnArea = 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, + }; + + var intelligence = new DoppelgangerMonsterIntelligence(this._path, pathPosition, walksAlongPath, attacksFirst, this.OnMonsterReachedMagicCircleAsync, this.Logger); + var monster = new Monster(spawnArea, monsterDefinition, this.Map, this.DropGenerator, intelligence, this._gameContext.PlugInManager, this._gameContext.PathFinderPool, this); + intelligence.Npc = monster; + try + { + monster.Initialize(); + } + catch (InvalidOperationException ex) + { + this.Logger.LogWarning(ex, "{context}: Couldn't spawn monster {monsterNumber} at the path position {pathPosition}.", this, monsterNumber, 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 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.Map.RemoveAsync(monster).ConfigureAwait(false); + monster.Dispose(); + } } diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs index 69aa4b128..36a926ad4 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs @@ -22,6 +22,65 @@ public class DoppelgangerEventDefinition /// 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 numbers of the monsters of which a herd consists. Each monster + /// of a herd is chosen randomly. + /// + public IList HerdMonsterNumbers { 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 numbers of the monsters which always attack players in their range. + /// + public IList AlwaysAttackingMonsterNumbers { 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 number of the ice walker monster. + /// + public short IceWalkerNumber { get; set; } = 531; + + /// + /// 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 paths of the monsters, one for each event map. /// @@ -33,12 +92,44 @@ public class DoppelgangerEventDefinition /// The created definition. public static DoppelgangerEventDefinition CreateDefault() { + 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. + HerdMonsterNumbers = [doppelganger, 534, 535, 536, 537, 539], + AlwaysAttackingMonsterNumbers = [doppelganger], + 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), MonsterNumbers = [madButcher, doppelgangerDarkLord] }, + new DoppelgangerMonsterSpawn { SpawnTime = TimeSpan.FromMinutes(4), MonsterNumbers = [madButcher, doppelgangerDarkLord] }, + new DoppelgangerMonsterSpawn { SpawnTime = TimeSpan.FromMinutes(7), MonsterNumbers = [terribleButcher, doppelgangerDarkLord] }, + ], Paths = CreateDefaultPaths(), }; } + /// + /// 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. /// diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerHerdSize.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerHerdSize.cs new file mode 100644 index 000000000..0888dea9e --- /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 000000000..c21550b6e --- /dev/null +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs @@ -0,0 +1,260 @@ +// +// 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(_ => this.SafeTick(), 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; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")] + private async void SafeTick() + { + 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); + } + } + + private async ValueTask TickAsync() + { + if (this._monster is not { IsAlive: true } monster + || Volatile.Read(ref this._hasReachedMagicCircle) != 0) + { + return; + } + + if (this.UpdatePathPosition(monster.Position) == this._path.Count - 1) + { + if (Interlocked.Exchange(ref this._hasReachedMagicCircle, 1) == 0) + { + this.Dispose(); + await this._onMagicCircleReached(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); + } + } + + /// + /// 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 = area.Center; + 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/DoppelgangerMonsterSpawn.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterSpawn.cs new file mode 100644 index 000000000..944609130 --- /dev/null +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterSpawn.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; + +/// +/// 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 numbers of the monsters which spawn. + /// + public IList MonsterNumbers { get; set; } = new List(); +} diff --git a/src/GameLogic/MiniGames/Doppelganger/IDoppelgangerEventViewPlugIn.cs b/src/GameLogic/MiniGames/Doppelganger/IDoppelgangerEventViewPlugIn.cs index 736086e86..a5c772b8c 100644 --- a/src/GameLogic/MiniGames/Doppelganger/IDoppelgangerEventViewPlugIn.cs +++ b/src/GameLogic/MiniGames/Doppelganger/IDoppelgangerEventViewPlugIn.cs @@ -45,6 +45,11 @@ public interface IDoppelgangerEventViewPlugIn : IViewPlugIn /// 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. diff --git a/src/GameServer/RemoteView/MiniGames/DoppelgangerEventViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/DoppelgangerEventViewPlugIn.cs index 69b7afccf..0542c840f 100644 --- a/src/GameServer/RemoteView/MiniGames/DoppelgangerEventViewPlugIn.cs +++ b/src/GameServer/RemoteView/MiniGames/DoppelgangerEventViewPlugIn.cs @@ -55,6 +55,12 @@ 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) { diff --git a/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs index 20a5cb23c..984531b8f 100644 --- a/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs +++ b/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs @@ -15,7 +15,7 @@ namespace MUnique.OpenMU.Persistence.Initialization.Updates; /// Adds the doppelganger event configuration to an existing Season 6 database. /// /// -/// It adds the Mirror of Dimensions as entrance ticket and the s, +/// It adds the Mirror of Dimensions as entrance ticket, the monsters and the s, /// opens the entrance window when talking to Lugard, and lets players who die inside the /// event maps respawn at Elvenland. /// @@ -73,6 +73,11 @@ protected override ValueTask ApplyAsync(IContext context, GameConfiguration game map.SafezoneMap = elvenland; } + if (!gameConfiguration.Monsters.Any(monster => monster.Number == DoppelgangerMonsters.FirstMonsterNumber)) + { + new DoppelgangerMonsters(context, gameConfiguration).Initialize(); + } + if (!gameConfiguration.MiniGameDefinitions.Any(definition => definition.Type == MiniGameType.Doppelganger)) { new DoppelgangerInitializer(context, gameConfiguration).Initialize(); diff --git a/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerInitializer.cs index 3138b64fb..13918936b 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerInitializer.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerInitializer.cs @@ -32,6 +32,11 @@ public DoppelgangerInitializer(IContext context, GameConfiguration gameConfigura /// public override void Initialize() { + if (!this.GameConfiguration.Monsters.Any(monster => monster.Number == DoppelgangerMonsters.FirstMonsterNumber)) + { + new DoppelgangerMonsters(this.Context, this.GameConfiguration).Initialize(); + } + var ticket = this.GameConfiguration.Items.Single(item => item is { Group: 14, Number: 111 }); for (var i = 0; i < MapNumbers.Length; i++) { diff --git a/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs new file mode 100644 index 000000000..ccdbad9e5 --- /dev/null +++ b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs @@ -0,0 +1,106 @@ +// +// 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 the base values of the original season 6 server data. +/// +internal class DoppelgangerMonsters : InitializerBase +{ + /// + /// The number of the first monster of the doppelganger event. + /// + internal const short FirstMonsterNumber = 529; + + /// + /// 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); + } + + 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/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs index 357fe26be..11b1a94a8 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs @@ -51,6 +51,11 @@ public async Task UpdateAddsEventToExistingDatabaseAsync() } gameConfiguration.Items.Remove(gameConfiguration.Items.Single(item => item is { Group: 14, Number: 111 })); + 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))) { @@ -64,6 +69,29 @@ public async Task UpdateAddsEventToExistingDatabaseAsync() 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); + } + + await new AddDoppelgangerDataUpdatePlugIn().ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + + AssertEventData(gameConfiguration); + } + private static async Task CreateSeason6ConfigurationAsync() { var contextProvider = new InMemoryPersistenceContextProvider(); @@ -83,6 +111,9 @@ private static void AssertEventData(GameConfiguration gameConfiguration) 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 == ticket)); + 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))); 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), diff --git a/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs index 111273c11..461448c5d 100644 --- a/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs +++ b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs @@ -56,6 +56,22 @@ public void PathAreaBounds() Assert.That(area.Center, 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 there is no position on a map without a path. /// From 953eb6be93ad24b957c49f03fb38f6666ee184aa Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:50:18 -0300 Subject: [PATCH 06/18] fix(doppelganger): place entering players on walkable coordinates The entrance gates of the event maps contained non-walkable coordinates, up to 39% of the area on the fourth map. The position within a gate is chosen randomly without checking the terrain, and a player on a non-walkable coordinate gets warped to the safezone map. So it happened that a player got warped out of the event right after entering, which already consumed the ticket. The gates are reduced to their walkable part, and the update plug-in adjusts them in existing databases. Co-Authored-By: Claude Opus 5.5 --- .../AddDoppelgangerDataUpdatePlugIn.cs | 20 ++++++++++++++++++- .../Initialization/VersionSeasonSix/Gates.cs | 9 +++++---- .../DoppelgangerDataTest.cs | 15 ++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs index 984531b8f..ca773390a 100644 --- a/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs +++ b/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs @@ -17,7 +17,9 @@ namespace MUnique.OpenMU.Persistence.Initialization.Updates; /// /// It adds the Mirror of Dimensions as entrance ticket, the monsters and the s, /// opens the entrance window when talking to Lugard, and lets players who die inside the -/// event maps respawn at Elvenland. +/// 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)] @@ -71,6 +73,10 @@ protected override ValueTask ApplyAsync(IContext context, GameConfiguration game 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)) @@ -85,4 +91,16 @@ protected override ValueTask ApplyAsync(IContext context, GameConfiguration game 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/VersionSeasonSix/Gates.cs b/src/Persistence/Initialization/VersionSeasonSix/Gates.cs index 6d14dcb32..3982367a1 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Gates.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Gates.cs @@ -423,10 +423,11 @@ private IDictionary CreateTargetGates(IDictionary 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); @@ -118,5 +122,16 @@ private static void AssertEventData(GameConfiguration gameConfiguration) 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), + })); } } From ab41925690e4a59de0e8e2b1a33ca6246527b7c5 Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:02:30 -0300 Subject: [PATCH 07/18] feat(doppelganger): add the interim and final reward chests - When a butcher dies, three interim reward chests appear around it. Only one of them can be opened by attacking it, the others disappear then. Unopened chests disappear after a minute. - An opened interim chest contains either larvae (one for each player which started the event) or items. The drop generator of the event suppresses the item drop, when larvae came out of it. - When the players successfully defended the magic circle, the final reward chest appears next to a remaining player. - The chests (541, 542) are now destructibles with their own drop groups: jewels, and with a small chance a Loch's Feather or Crest of Monarch, similar to the original item bags. The update plug-in configures them in existing databases. - Monsters which don't walk along the path can't reach the magic circle, so larvae next to it aren't counted. Co-Authored-By: Claude Opus 5.5 --- .../Doppelganger/DoppelgangerContext.cs | 219 ++++++++++++++++-- .../Doppelganger/DoppelgangerDropGenerator.cs | 81 +++++++ .../DoppelgangerEventDefinition.cs | 36 +++ .../DoppelgangerMonsterIntelligence.cs | 4 +- .../AddDoppelgangerDataUpdatePlugIn.cs | 6 +- .../Events/DoppelgangerMonsters.cs | 66 ++++++ .../DoppelgangerDataTest.cs | 8 + .../DoppelgangerDropGeneratorTest.cs | 44 ++++ 8 files changed, 445 insertions(+), 19 deletions(-) create mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerDropGenerator.cs create mode 100644 tests/MUnique.OpenMU.Tests/DoppelgangerDropGeneratorTest.cs diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs index b58b8c0aa..7e1489b7a 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; using System.Threading; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Pathfinding; /// /// The context of a doppelganger event game. @@ -17,6 +18,8 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; /// 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. +/// Killed butchers leave interim reward chests behind, of which only one can be opened. 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. @@ -45,10 +48,18 @@ public sealed class DoppelgangerContext : MiniGameContext private readonly ConcurrentDictionary _iceWalkers = new(); + /// + /// The interim reward chests, with the group of chests which appeared together. + /// + private readonly ConcurrentDictionary _interimChests = new(); + + private readonly DoppelgangerDropGenerator _dropGenerator; + private DateTime _gameStartedUtc; private DateTime _gameEndsAtUtc; private int _goalCount; private int _lastShownMonsterPosition = -1; + private int _initialPlayerCount; /// /// Initializes a new instance of the class. @@ -68,6 +79,8 @@ public DoppelgangerContext( this._definition = DoppelgangerEventDefinition.CreateDefault(); var mapNumber = (short)this.Map.MapId; this._path = this._definition.Paths.FirstOrDefault(path => path.MapNumber == mapNumber)?.Areas ?? []; + this._dropGenerator = new DoppelgangerDropGenerator(this.DropGenerator, this._definition.InterimRewardChestNumber); + this.DropGenerator = this._dropGenerator; } /// @@ -113,6 +126,7 @@ protected override async ValueTask OnGameStartAsync(ICollection players) await base.OnGameStartAsync(players).ConfigureAwait(false); this._gameStartedUtc = DateTime.UtcNow; + this._initialPlayerCount = players.Count; this._gameEndsAtUtc = this._gameStartedUtc.Add(this.Definition.GameDuration); await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(async p => { @@ -139,6 +153,11 @@ protected override async void OnMonsterDied(object? sender, DeathInformation e) } this._pathMonsters.TryRemove(monster, out _); + if (this.State == MiniGameState.Playing && this._definition.InterimChestMonsterNumbers.Contains(monster.Definition.Number)) + { + 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); @@ -151,6 +170,52 @@ protected override async void OnMonsterDied(object? sender, DeathInformation e) } } + /// +#pragma warning disable VSTHRD100 // Avoid async void methods + protected override async void OnDestructibleDied(object? sender, DeathInformation e) +#pragma warning restore VSTHRD100 + { + try + { + base.OnDestructibleDied(sender, e); + + if (sender is not Destructible chest + || !this._interimChests.TryRemove(chest, out var group) + || !group.TryOpen()) + { + return; + } + + // The content has to be decided before the drop of the chest is generated. + var containsLarvae = Rand.NextRandomBool(this._definition.LarvaChance); + if (containsLarvae) + { + this._dropGenerator.SuppressNextInterimChestDrop(); + } + + foreach (var otherChest in group.Chests.Where(c => c != chest)) + { + if (this._interimChests.TryRemove(otherChest, out _)) + { + await this.RemoveNpcAsync(otherChest).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.LarvaNumber, area, 0, false, true).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + this.Logger.LogError(ex, "{context}: Unexpected error in OnDestructibleDied.", this); + } + } + /// #pragma warning disable VSTHRD100 // Avoid async void methods protected override async void OnPlayerDied(object? sender, DeathInformation e) @@ -188,6 +253,7 @@ 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) @@ -205,9 +271,24 @@ await player.InvokeViewPlugInAsync(async p => }).ConfigureAwait(false); } + if (result == DoppelgangerResult.Success && finishers.FirstOrDefault(player => player.IsAlive) is { } firstFinisher) + { + await this.SpawnDestructibleAsync(this._definition.FinalRewardChestNumber, GetAreaAround(firstFinisher.Position, 1)).ConfigureAwait(false); + } + await base.GameEndedAsync(finishers).ConfigureAwait(false); } + 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. /// @@ -410,26 +491,19 @@ private async Task RunIceWalkerMissionAsync(CancellationToken cancellationToken) } } - private async ValueTask SpawnMonsterAsync(short monsterNumber, int pathPosition, bool walksAlongPath, bool attacksFirst) + private ValueTask SpawnMonsterAsync(short monsterNumber, int pathPosition, bool walksAlongPath, bool attacksFirst) { - if (this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == monsterNumber) is not { } monsterDefinition) + return this.SpawnMonsterAsync(monsterNumber, this._path[pathPosition], pathPosition, walksAlongPath, attacksFirst); + } + + private async ValueTask SpawnMonsterAsync(short monsterNumber, DoppelgangerPathArea area, int pathPosition, bool walksAlongPath, bool attacksFirst) + { + if (this.CreateSpawnArea(monsterNumber, area) is not { } spawnArea) { - this.Logger.LogWarning("{context}: Monster definition {monsterNumber} not found.", this, monsterNumber); return null; } - var area = this._path[pathPosition]; - var spawnArea = 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, - }; + var monsterDefinition = spawnArea.MonsterDefinition!; var intelligence = new DoppelgangerMonsterIntelligence(this._path, pathPosition, walksAlongPath, attacksFirst, this.OnMonsterReachedMagicCircleAsync, this.Logger); var monster = new Monster(spawnArea, monsterDefinition, this.Map, this.DropGenerator, intelligence, this._gameContext.PlugInManager, this._gameContext.PathFinderPool, this); @@ -452,6 +526,99 @@ private async Task RunIceWalkerMissionAsync(CancellationToken cancellationToken) 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.InterimRewardChestNumber, 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 SpawnDestructibleAsync(short number, DoppelgangerPathArea area) + { + if (this.CreateSpawnArea(number, area) is not { } spawnArea) + { + return null; + } + + 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 {number} around {area}.", this, number, area); + destructible.Dispose(); + return null; + } + + await this.Map.AddAsync(destructible).ConfigureAwait(false); + destructible.OnSpawn(); + return destructible; + } + + private MonsterSpawnArea? CreateSpawnArea(short monsterNumber, DoppelgangerPathArea area) + { + if (this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == monsterNumber) is not { } monsterDefinition) + { + this.Logger.LogWarning("{context}: Monster definition {monsterNumber} not found.", this, monsterNumber); + 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(); + } + private async ValueTask OnMonsterReachedMagicCircleAsync(Monster monster) { await this.RemoveMonsterAsync(monster).ConfigureAwait(false); @@ -462,7 +629,25 @@ private async ValueTask RemoveMonsterAsync(Monster monster) { this._pathMonsters.TryRemove(monster, out _); this._iceWalkers.TryRemove(monster, out _); - await this.Map.RemoveAsync(monster).ConfigureAwait(false); - monster.Dispose(); + await this.RemoveNpcAsync(monster).ConfigureAwait(false); + } + + /// + /// 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/DoppelgangerDropGenerator.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerDropGenerator.cs new file mode 100644 index 000000000..9243c4c0f --- /dev/null +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerDropGenerator.cs @@ -0,0 +1,81 @@ +// +// 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; + +/// +/// The drop generator of the doppelganger event. It delegates to the drop generator of the +/// game, but suppresses the drop of an interim reward chest, when larvae came out of it instead. +/// +/// +/// The drop of a killed object is generated a moment after its death, so the context decides +/// about the content of the chest first and tells this generator to suppress the drop. +/// +public sealed class DoppelgangerDropGenerator : IDropGenerator +{ + private readonly IDropGenerator _dropGenerator; + private readonly short _interimRewardChestNumber; + private int _suppressedInterimChestDrops; + + /// + /// Initializes a new instance of the class. + /// + /// The drop generator of the game. + /// The number of the interim reward chest. + public DoppelgangerDropGenerator(IDropGenerator dropGenerator, short interimRewardChestNumber) + { + this._dropGenerator = dropGenerator; + this._interimRewardChestNumber = interimRewardChestNumber; + } + + /// + /// Suppresses the drop of the next opened interim reward chest. + /// + public void SuppressNextInterimChestDrop() + { + Interlocked.Increment(ref this._suppressedInterimChestDrops); + } + + /// + public ValueTask<(IEnumerable Items, uint? Money)> GenerateItemDropsAsync(MonsterDefinition monster, int gainedExperience, Player player) + { + if (monster.Number == this._interimRewardChestNumber && this.TryConsumeSuppressedDrop()) + { + return ValueTask.FromResult<(IEnumerable Items, uint? Money)>(([], null)); + } + + return this._dropGenerator.GenerateItemDropsAsync(monster, gainedExperience, player); + } + + /// + public Item? GenerateItemDrop(DropItemGroup group) + { + return this._dropGenerator.GenerateItemDrop(group); + } + + /// + public (Item? Item, uint? Money, ItemDropEffect DropEffect) GenerateItemDrop(IEnumerable groups) + { + return this._dropGenerator.GenerateItemDrop(groups); + } + + private bool TryConsumeSuppressedDrop() + { + var current = Volatile.Read(ref this._suppressedInterimChestDrops); + while (current > 0) + { + var previous = Interlocked.CompareExchange(ref this._suppressedInterimChestDrops, current - 1, current); + if (previous == current) + { + return true; + } + + current = previous; + } + + return false; + } +} diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs index 36a926ad4..e26ad98a3 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs @@ -81,6 +81,41 @@ public class DoppelgangerEventDefinition /// public int IceWalkerMaximumPosition { get; set; } = 18; + /// + /// Gets or sets the numbers of the monsters which leave interim reward chests behind when they die. + /// + public IList InterimChestMonsterNumbers { get; set; } = new List(); + + /// + /// Gets or sets the number of the interim reward chest. + /// + public short InterimRewardChestNumber { get; set; } = 541; + + /// + /// 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 number of the larva monster. One larva comes out of a chest for each player which started the event. + /// + public short LarvaNumber { get; set; } = 532; + + /// + /// Gets or sets the number of the final reward chest, which appears when the players successfully defended the magic circle. + /// + public short FinalRewardChestNumber { get; set; } = 542; + /// /// Gets or sets the paths of the monsters, one for each event map. /// @@ -102,6 +137,7 @@ public static DoppelgangerEventDefinition CreateDefault() // The dark lord clone (538) only appears as additional spawn. HerdMonsterNumbers = [doppelganger, 534, 535, 536, 537, 539], AlwaysAttackingMonsterNumbers = [doppelganger], + InterimChestMonsterNumbers = [terribleButcher, madButcher], HerdSizes = [ new DoppelgangerHerdSize { StartsAfter = TimeSpan.Zero, BaseCount = 1 }, diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs index c21550b6e..a672adbb1 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs @@ -151,7 +151,9 @@ private async ValueTask TickAsync() return; } - if (this.UpdatePathPosition(monster.Position) == this._path.Count - 1) + // 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) { diff --git a/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs index ca773390a..0b5058fd5 100644 --- a/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs +++ b/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs @@ -15,7 +15,7 @@ namespace MUnique.OpenMU.Persistence.Initialization.Updates; /// Adds the doppelganger event configuration to an existing Season 6 database. /// /// -/// It adds the Mirror of Dimensions as entrance ticket, the monsters and the s, +/// It adds the Mirror of Dimensions as entrance ticket, 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 @@ -83,6 +83,10 @@ protected override ValueTask ApplyAsync(IContext context, GameConfiguration game { new DoppelgangerMonsters(context, gameConfiguration).Initialize(); } + else + { + new DoppelgangerMonsters(context, gameConfiguration).ConfigureRewardChests(); + } if (!gameConfiguration.MiniGameDefinitions.Any(definition => definition.Type == MiniGameType.Doppelganger)) { diff --git a/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs index ccdbad9e5..943abef27 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs @@ -22,6 +22,16 @@ internal class DoppelgangerMonsters : InitializerBase /// 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. /// @@ -46,6 +56,62 @@ public override void Initialize() 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, similar to the original item bags. + /// + 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( diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs index 103c4e8a4..70634d319 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs @@ -118,6 +118,14 @@ private static void AssertEventData(GameConfiguration gameConfiguration) 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), diff --git a/tests/MUnique.OpenMU.Tests/DoppelgangerDropGeneratorTest.cs b/tests/MUnique.OpenMU.Tests/DoppelgangerDropGeneratorTest.cs new file mode 100644 index 000000000..314af5920 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/DoppelgangerDropGeneratorTest.cs @@ -0,0 +1,44 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using Moq; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; +using NUnit.Framework; + +/// +/// Tests for the . +/// +[TestFixture] +public class DoppelgangerDropGeneratorTest +{ + private const short InterimChestNumber = 541; + + /// + /// Tests that a suppressed drop only affects the next interim chest, and not other monsters. + /// + [Test] + public async Task SuppressesOnlyNextInterimChestDropAsync() + { + var item = new Mock().Object; + var innerGenerator = new Mock(); + innerGenerator + .Setup(g => g.GenerateItemDropsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.FromResult<(IEnumerable Items, uint? Money)>((new[] { item }, null))); + var generator = new DoppelgangerDropGenerator(innerGenerator.Object, InterimChestNumber); + var chest = new MonsterDefinition { Number = InterimChestNumber }; + var otherMonster = new MonsterDefinition { Number = 533 }; + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + + generator.SuppressNextInterimChestDrop(); + + Assert.That((await generator.GenerateItemDropsAsync(otherMonster, 0, player).ConfigureAwait(false)).Items, Is.Not.Empty); + Assert.That((await generator.GenerateItemDropsAsync(chest, 0, player).ConfigureAwait(false)).Items, Is.Empty); + Assert.That((await generator.GenerateItemDropsAsync(chest, 0, player).ConfigureAwait(false)).Items, Is.Not.Empty); + } +} From fa91d96f3a261e7f1c04fc9a0e532d60d612bf59 Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:22:47 -0300 Subject: [PATCH 08/18] fix(doppelganger): open the reward chests by talking to them The client handles the reward chests (541, 542) as NPCs, so clicking on them sends a talk request instead of an attack, and the server answered that talking to them is not implemented. The DoppelgangerRewardChestPlugIn now opens a chest when a player talks to it. The client shows the opening as the death of the chest, then the items drop or the larvae appear, and the chest is removed. Because the content is decided when opening the chest, the special drop generator isn't required anymore. The chests got a high health, so that attacks don't destroy them. Co-Authored-By: Claude Opus 5.5 --- .../Doppelganger/DoppelgangerContext.cs | 170 ++++++++++++------ .../Doppelganger/DoppelgangerDropGenerator.cs | 81 --------- .../DoppelgangerEventDefinition.cs | 12 +- .../PlugIns/DoppelgangerRewardChestPlugIn.cs | 35 ++++ .../DoppelgangerDropGeneratorTest.cs | 44 ----- 5 files changed, 156 insertions(+), 186 deletions(-) delete mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerDropGenerator.cs create mode 100644 src/GameLogic/PlugIns/DoppelgangerRewardChestPlugIn.cs delete mode 100644 tests/MUnique.OpenMU.Tests/DoppelgangerDropGeneratorTest.cs diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs index 7e1489b7a..f7bd300e8 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; using System.Threading; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameLogic.Views.World; using MUnique.OpenMU.Pathfinding; /// @@ -18,8 +19,8 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; /// 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. -/// Killed butchers leave interim reward chests behind, of which only one can be opened. It contains -/// either items or larvae. When the defense succeeded, a final reward chest appears. +/// 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. @@ -27,11 +28,26 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; /// 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; @@ -53,13 +69,13 @@ public sealed class DoppelgangerContext : MiniGameContext /// private readonly ConcurrentDictionary _interimChests = new(); - private readonly DoppelgangerDropGenerator _dropGenerator; - private DateTime _gameStartedUtc; private DateTime _gameEndsAtUtc; private int _goalCount; private int _lastShownMonsterPosition = -1; private int _initialPlayerCount; + private Destructible? _finalChest; + private int _isFinalChestOpened; /// /// Initializes a new instance of the class. @@ -79,8 +95,6 @@ public DoppelgangerContext( this._definition = DoppelgangerEventDefinition.CreateDefault(); var mapNumber = (short)this.Map.MapId; this._path = this._definition.Paths.FirstOrDefault(path => path.MapNumber == mapNumber)?.Areas ?? []; - this._dropGenerator = new DoppelgangerDropGenerator(this.DropGenerator, this._definition.InterimRewardChestNumber); - this.DropGenerator = this._dropGenerator; } /// @@ -120,6 +134,73 @@ await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync + /// 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; + } + + 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.LarvaNumber, area, 0, false, true).ConfigureAwait(false); + } + } + else + { + await this.DropChestItemsAsync(player, chest).ConfigureAwait(false); + } + + _ = Task.Run(() => this.RemoveOpenedChestAsync(chest)); + } + /// protected override async ValueTask OnGameStartAsync(ICollection players) { @@ -170,52 +251,6 @@ protected override async void OnMonsterDied(object? sender, DeathInformation e) } } - /// -#pragma warning disable VSTHRD100 // Avoid async void methods - protected override async void OnDestructibleDied(object? sender, DeathInformation e) -#pragma warning restore VSTHRD100 - { - try - { - base.OnDestructibleDied(sender, e); - - if (sender is not Destructible chest - || !this._interimChests.TryRemove(chest, out var group) - || !group.TryOpen()) - { - return; - } - - // The content has to be decided before the drop of the chest is generated. - var containsLarvae = Rand.NextRandomBool(this._definition.LarvaChance); - if (containsLarvae) - { - this._dropGenerator.SuppressNextInterimChestDrop(); - } - - foreach (var otherChest in group.Chests.Where(c => c != chest)) - { - if (this._interimChests.TryRemove(otherChest, out _)) - { - await this.RemoveNpcAsync(otherChest).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.LarvaNumber, area, 0, false, true).ConfigureAwait(false); - } - } - } - catch (Exception ex) - { - this.Logger.LogError(ex, "{context}: Unexpected error in OnDestructibleDied.", this); - } - } - /// #pragma warning disable VSTHRD100 // Avoid async void methods protected override async void OnPlayerDied(object? sender, DeathInformation e) @@ -273,7 +308,7 @@ await player.InvokeViewPlugInAsync(async p => if (result == DoppelgangerResult.Success && finishers.FirstOrDefault(player => player.IsAlive) is { } firstFinisher) { - await this.SpawnDestructibleAsync(this._definition.FinalRewardChestNumber, GetAreaAround(firstFinisher.Position, 1)).ConfigureAwait(false); + this._finalChest = await this.SpawnDestructibleAsync(this._definition.FinalRewardChestNumber, GetAreaAround(firstFinisher.Position, 1)).ConfigureAwait(false); } await base.GameEndedAsync(finishers).ConfigureAwait(false); @@ -568,6 +603,33 @@ private async Task RemoveUnopenedChestsAsync(InterimChestGroup group, Cancellati } } + 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 Task RemoveOpenedChestAsync(Destructible chest) + { + try + { + await Task.Delay(OpenedChestRemovalDelay).ConfigureAwait(false); + await this.RemoveNpcAsync(chest).ConfigureAwait(false); + } + catch (Exception ex) + { + this.Logger.LogDebug(ex, "{context}: Failed to remove the opened chest {chest}.", this, chest); + } + } + private async ValueTask SpawnDestructibleAsync(short number, DoppelgangerPathArea area) { if (this.CreateSpawnArea(number, area) is not { } spawnArea) @@ -575,6 +637,8 @@ private async Task RemoveUnopenedChestsAsync(InterimChestGroup group, Cancellati return null; } + spawnArea.MaximumHealthOverride = ChestHealth; + var destructible = new Destructible(spawnArea, spawnArea.MonsterDefinition!, this.Map, this, this.DropGenerator, this._gameContext.PlugInManager); try { diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerDropGenerator.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerDropGenerator.cs deleted file mode 100644 index 9243c4c0f..000000000 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerDropGenerator.cs +++ /dev/null @@ -1,81 +0,0 @@ -// -// 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; - -/// -/// The drop generator of the doppelganger event. It delegates to the drop generator of the -/// game, but suppresses the drop of an interim reward chest, when larvae came out of it instead. -/// -/// -/// The drop of a killed object is generated a moment after its death, so the context decides -/// about the content of the chest first and tells this generator to suppress the drop. -/// -public sealed class DoppelgangerDropGenerator : IDropGenerator -{ - private readonly IDropGenerator _dropGenerator; - private readonly short _interimRewardChestNumber; - private int _suppressedInterimChestDrops; - - /// - /// Initializes a new instance of the class. - /// - /// The drop generator of the game. - /// The number of the interim reward chest. - public DoppelgangerDropGenerator(IDropGenerator dropGenerator, short interimRewardChestNumber) - { - this._dropGenerator = dropGenerator; - this._interimRewardChestNumber = interimRewardChestNumber; - } - - /// - /// Suppresses the drop of the next opened interim reward chest. - /// - public void SuppressNextInterimChestDrop() - { - Interlocked.Increment(ref this._suppressedInterimChestDrops); - } - - /// - public ValueTask<(IEnumerable Items, uint? Money)> GenerateItemDropsAsync(MonsterDefinition monster, int gainedExperience, Player player) - { - if (monster.Number == this._interimRewardChestNumber && this.TryConsumeSuppressedDrop()) - { - return ValueTask.FromResult<(IEnumerable Items, uint? Money)>(([], null)); - } - - return this._dropGenerator.GenerateItemDropsAsync(monster, gainedExperience, player); - } - - /// - public Item? GenerateItemDrop(DropItemGroup group) - { - return this._dropGenerator.GenerateItemDrop(group); - } - - /// - public (Item? Item, uint? Money, ItemDropEffect DropEffect) GenerateItemDrop(IEnumerable groups) - { - return this._dropGenerator.GenerateItemDrop(groups); - } - - private bool TryConsumeSuppressedDrop() - { - var current = Volatile.Read(ref this._suppressedInterimChestDrops); - while (current > 0) - { - var previous = Interlocked.CompareExchange(ref this._suppressedInterimChestDrops, current - 1, current); - if (previous == current) - { - return true; - } - - current = previous; - } - - return false; - } -} diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs index e26ad98a3..525b61cf7 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs @@ -202,10 +202,9 @@ private static IList CreateDefaultPaths() { return [ - // Doppelganger 1 (Ice), map 65 new DoppelgangerPath { - MapNumber = 65, + MapNumber = 65, // Doppelganger 1 (Ice) Areas = [ new(220, 99, 230, 107), @@ -233,10 +232,9 @@ private static IList CreateDefaultPaths() new(192, 24, 202, 31), ], }, - // Doppelganger 2 (Fire), map 66 new DoppelgangerPath { - MapNumber = 66, + MapNumber = 66, // Doppelganger 2 (Fire) Areas = [ new(108, 179, 119, 186), @@ -264,10 +262,9 @@ private static IList CreateDefaultPaths() new(133, 66, 143, 75), ], }, - // Doppelganger 3 (Water), map 67 new DoppelgangerPath { - MapNumber = 67, + MapNumber = 67, // Doppelganger 3 (Water) Areas = [ new(107, 149, 114, 155), @@ -295,10 +292,9 @@ private static IList CreateDefaultPaths() new(104, 55, 115, 63), ], }, - // Doppelganger 4 (Ground), map 68 new DoppelgangerPath { - MapNumber = 68, + MapNumber = 68, // Doppelganger 4 (Ground) Areas = [ new(38, 105, 48, 113), diff --git a/src/GameLogic/PlugIns/DoppelgangerRewardChestPlugIn.cs b/src/GameLogic/PlugIns/DoppelgangerRewardChestPlugIn.cs new file mode 100644 index 000000000..0460c132b --- /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(DoppelgangerRewardChestPlugIn), Description = "Opens the reward chests of the doppelganger event.")] +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/tests/MUnique.OpenMU.Tests/DoppelgangerDropGeneratorTest.cs b/tests/MUnique.OpenMU.Tests/DoppelgangerDropGeneratorTest.cs deleted file mode 100644 index 314af5920..000000000 --- a/tests/MUnique.OpenMU.Tests/DoppelgangerDropGeneratorTest.cs +++ /dev/null @@ -1,44 +0,0 @@ -// -// Licensed under the MIT License. See LICENSE file in the project root for full license information. -// - -namespace MUnique.OpenMU.Tests; - -using Moq; -using MUnique.OpenMU.DataModel.Configuration; -using MUnique.OpenMU.DataModel.Entities; -using MUnique.OpenMU.GameLogic; -using MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; -using NUnit.Framework; - -/// -/// Tests for the . -/// -[TestFixture] -public class DoppelgangerDropGeneratorTest -{ - private const short InterimChestNumber = 541; - - /// - /// Tests that a suppressed drop only affects the next interim chest, and not other monsters. - /// - [Test] - public async Task SuppressesOnlyNextInterimChestDropAsync() - { - var item = new Mock().Object; - var innerGenerator = new Mock(); - innerGenerator - .Setup(g => g.GenerateItemDropsAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(ValueTask.FromResult<(IEnumerable Items, uint? Money)>((new[] { item }, null))); - var generator = new DoppelgangerDropGenerator(innerGenerator.Object, InterimChestNumber); - var chest = new MonsterDefinition { Number = InterimChestNumber }; - var otherMonster = new MonsterDefinition { Number = 533 }; - var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); - - generator.SuppressNextInterimChestDrop(); - - Assert.That((await generator.GenerateItemDropsAsync(otherMonster, 0, player).ConfigureAwait(false)).Items, Is.Not.Empty); - Assert.That((await generator.GenerateItemDropsAsync(chest, 0, player).ConfigureAwait(false)).Items, Is.Empty); - Assert.That((await generator.GenerateItemDropsAsync(chest, 0, player).ConfigureAwait(false)).Items, Is.Not.Empty); - } -} From dff9b021bd3007cd344b41806cdf73087a6f9720 Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:25:51 -0300 Subject: [PATCH 09/18] feat(doppelganger): scale the monsters by the level and number of players The monsters get stronger depending on the highest player level (including the master level) and the number of players which started the game, like in the original event. The multipliers for the level, health, damage and defense are taken from the original season 6 server data, in ranges of ten player levels up to 800. When the players fail to kill the ice walkers in time, the following herds and additional monsters get twice the health, damage and defense. The multipliers are added to the attributes of each monster instance before it's initialized, so they don't affect the monster definitions. Co-Authored-By: Claude Opus 5.5 --- .../Doppelganger/DoppelgangerContext.cs | 90 +++++++++++++- ...elgangerEventDefinition.MonsterScalings.cs | 115 ++++++++++++++++++ .../DoppelgangerEventDefinition.cs | 28 ++++- .../DoppelgangerMonsterScaling.cs | 37 ++++++ .../DoppelgangerEventDefinitionTest.cs | 34 ++++++ .../MonsterInstanceMultiplierTest.cs | 74 +++++++++++ 6 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs create mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterScaling.cs create mode 100644 tests/MUnique.OpenMU.Tests/MonsterInstanceMultiplierTest.cs diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs index f7bd300e8..cbb06b985 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs @@ -6,6 +6,8 @@ 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; @@ -19,6 +21,8 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; /// 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 @@ -74,6 +78,8 @@ public sealed class DoppelgangerContext : MiniGameContext private int _goalCount; private int _lastShownMonsterPosition = -1; private int _initialPlayerCount; + private MonsterMultipliers _monsterMultipliers = MonsterMultipliers.None; + private int _isIceWalkerMissionFailed; private Destructible? _finalChest; private int _isFinalChestOpened; @@ -208,6 +214,7 @@ protected override async ValueTask OnGameStartAsync(ICollection players) 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 => { @@ -425,7 +432,7 @@ private async Task RunMonsterSpawnsAsync(CancellationToken cancellationToken) { foreach (var monsterNumber in pendingSpawns[0].MonsterNumbers) { - await this.SpawnMonsterAsync(monsterNumber, 0, true, true).ConfigureAwait(false); + await this.SpawnMonsterAsync(monsterNumber, 0, true, true, true).ConfigureAwait(false); } pendingSpawns.RemoveAt(0); @@ -462,7 +469,7 @@ private async ValueTask SpawnHerdAsync(TimeSpan elapsed) var monsterNumber = this._definition.HerdMonsterNumbers[Rand.NextInt(0, this._definition.HerdMonsterNumbers.Count)]; var attacksFirst = this._definition.AlwaysAttackingMonsterNumbers.Contains(monsterNumber) || Rand.NextRandomBool(this._definition.AttackFirstChance); - await this.SpawnMonsterAsync(monsterNumber, 0, true, attacksFirst).ConfigureAwait(false); + await this.SpawnMonsterAsync(monsterNumber, 0, true, attacksFirst, true).ConfigureAwait(false); } } @@ -508,7 +515,8 @@ private async Task RunIceWalkerMissionAsync(CancellationToken cancellationToken) return; } - this.Logger.LogDebug("{context}: The ice walkers weren't killed in time.", this); + 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); @@ -526,12 +534,12 @@ private async Task RunIceWalkerMissionAsync(CancellationToken cancellationToken) } } - private ValueTask SpawnMonsterAsync(short monsterNumber, int pathPosition, bool walksAlongPath, bool attacksFirst) + private ValueTask SpawnMonsterAsync(short monsterNumber, int pathPosition, bool walksAlongPath, bool attacksFirst, bool isAffectedByMissionFailure = false) { - return this.SpawnMonsterAsync(monsterNumber, this._path[pathPosition], pathPosition, walksAlongPath, attacksFirst); + return this.SpawnMonsterAsync(monsterNumber, this._path[pathPosition], pathPosition, walksAlongPath, attacksFirst, isAffectedByMissionFailure); } - private async ValueTask SpawnMonsterAsync(short monsterNumber, DoppelgangerPathArea area, int pathPosition, bool walksAlongPath, bool attacksFirst) + private async ValueTask SpawnMonsterAsync(short monsterNumber, DoppelgangerPathArea area, int pathPosition, bool walksAlongPath, bool attacksFirst, bool isAffectedByMissionFailure = false) { if (this.CreateSpawnArea(monsterNumber, area) is not { } spawnArea) { @@ -543,6 +551,12 @@ private async Task RunIceWalkerMissionAsync(CancellationToken cancellationToken) var intelligence = new DoppelgangerMonsterIntelligence(this._path, pathPosition, walksAlongPath, attacksFirst, this.OnMonsterReachedMagicCircleAsync, this.Logger); var monster = new Monster(spawnArea, monsterDefinition, this.Map, this.DropGenerator, intelligence, this._gameContext.PlugInManager, this._gameContext.PathFinderPool, this); intelligence.Npc = monster; + + // 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(); @@ -683,6 +697,33 @@ private async ValueTask RemoveNpcAsync(NonPlayerCharacter npc) 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); @@ -696,6 +737,43 @@ private async ValueTask RemoveMonsterAsync(Monster monster) 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. /// diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs new file mode 100644 index 000000000..62dbeea6b --- /dev/null +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs @@ -0,0 +1,115 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; + +/// +/// The default multipliers for the monsters, taken from the original season 6 server data. +/// +public partial class DoppelgangerEventDefinition +{ + /// + /// Creates the multipliers of the monsters of the original season 6 event. Each entry applies + /// up to a player level (including the master level) and contains the multipliers for the + /// level, health, damage and defense for one to five players. + /// + private static IList CreateDefaultMonsterScalings() + { + return + [ + CreateScaling(10, [1f, 1f, 1f, 1f, 1f], [1f, 2f, 3f, 3.8f, 4.8f], [1f, 2f, 3f, 3.7f, 4.7f], [1f, 2f, 3f, 3.7f, 4.5f]), + CreateScaling(20, [1.2f, 1.2f, 1.2f, 1.2f, 1.2f], [2f, 2.2f, 3.2f, 4f, 5f], [1.15f, 2.05f, 3.05f, 3.75f, 4.75f], [1.35f, 2.15f, 3.15f, 3.85f, 4.65f]), + CreateScaling(30, [1.6f, 1.6f, 1.6f, 1.6f, 1.6f], [2.7f, 2.4f, 3.4f, 4.2f, 5.2f], [1.2f, 2.1f, 3.1f, 3.8f, 4.8f], [1.5f, 2.3f, 3.3f, 4f, 4.8f]), + CreateScaling(40, [1.9f, 1.9f, 1.9f, 1.9f, 1.9f], [3.4f, 2.6f, 3.6f, 4.4f, 5.4f], [1.23f, 2.15f, 3.15f, 3.85f, 4.85f], [1.65f, 2.45f, 3.45f, 4.15f, 4.95f]), + CreateScaling(50, [2.2f, 2.2f, 2.2f, 2.2f, 2.2f], [4.1f, 2.8f, 3.8f, 4.6f, 5.6f], [1.43f, 2.18f, 3.18f, 3.88f, 4.88f], [1.8f, 2.6f, 3.6f, 4.3f, 5.1f]), + CreateScaling(60, [2.5f, 2.5f, 2.5f, 2.5f, 2.5f], [4.8f, 3.5f, 4.5f, 5.3f, 6.3f], [1.63f, 2.38f, 3.38f, 4.08f, 5.08f], [1.95f, 2.75f, 3.75f, 4.45f, 5.25f]), + CreateScaling(70, [2.7f, 2.7f, 2.7f, 2.7f, 2.7f], [5.5f, 4f, 5f, 5.8f, 6.8f], [1.83f, 2.58f, 3.58f, 4.28f, 5.28f], [2.1f, 2.9f, 3.9f, 4.6f, 5.4f]), + CreateScaling(80, [3f, 3f, 3f, 3f, 3f], [6.2f, 4.5f, 5.5f, 6.3f, 7.3f], [2.1f, 2.78f, 3.78f, 4.48f, 5.48f], [2.25f, 3.05f, 4.05f, 4.75f, 5.55f]), + CreateScaling(90, [3.3f, 3.3f, 3.3f, 3.3f, 3.3f], [6.9f, 5f, 6f, 6.8f, 7.8f], [2.31f, 2.98f, 3.98f, 4.68f, 5.68f], [2.4f, 3.2f, 4.2f, 4.9f, 5.8f]), + CreateScaling(100, [3.6f, 3.6f, 3.6f, 3.6f, 3.6f], [7.6f, 6f, 7f, 7.8f, 8.8f], [2.51f, 3.28f, 4.28f, 4.98f, 5.98f], [2.55f, 3.35f, 4.35f, 5.05f, 6.85f]), + CreateScaling(110, [3.65f, 3.65f, 3.65f, 3.65f, 3.65f], [8.3f, 6.5f, 7.5f, 8.3f, 9.3f], [2.71f, 3.48f, 4.48f, 5.18f, 6.18f], [2.7f, 3.5f, 4.5f, 5.2f, 6f]), + CreateScaling(120, [3.69f, 3.69f, 3.69f, 3.69f, 3.69f], [9f, 6.7f, 7.7f, 8.5f, 9.5f], [2.91f, 3.68f, 4.68f, 5.38f, 6.38f], [2.85f, 3.65f, 4.65f, 5.35f, 6.15f]), + CreateScaling(130, [3.73f, 3.73f, 3.73f, 3.73f, 3.73f], [9.7f, 6.9f, 7.9f, 8.7f, 9.7f], [3.11f, 3.88f, 4.88f, 5.58f, 6.58f], [3f, 3.8f, 4.8f, 5.5f, 6.3f]), + CreateScaling(140, [3.77f, 3.77f, 3.77f, 3.77f, 3.77f], [10.4f, 7.1f, 8.1f, 8.9f, 9.9f], [3.31f, 4.08f, 5.08f, 5.78f, 6.78f], [3.15f, 3.95f, 4.95f, 5.65f, 6.45f]), + CreateScaling(150, [3.81f, 3.81f, 3.81f, 3.81f, 3.81f], [11.1f, 7.3f, 8.3f, 9.1f, 10.1f], [3.51f, 4.28f, 5.28f, 5.98f, 6.98f], [3.3f, 4.1f, 5.1f, 5.8f, 6.6f]), + CreateScaling(160, [3.85f, 3.85f, 3.85f, 3.85f, 3.85f], [11.8f, 8f, 9f, 9.8f, 10.8f], [3.71f, 4.48f, 5.48f, 6.18f, 7.18f], [3.45f, 4.25f, 5.25f, 5.95f, 6.75f]), + CreateScaling(170, [3.89f, 3.89f, 3.89f, 3.89f, 3.89f], [12.5f, 9f, 10f, 10.8f, 11.8f], [3.81f, 4.68f, 5.68f, 6.38f, 7.38f], [4.6f, 4.4f, 5.4f, 6.1f, 6.9f]), + CreateScaling(180, [3.93f, 3.93f, 3.93f, 3.93f, 3.93f], [13.2f, 11f, 12f, 11.8f, 12.8f], [3.91f, 6f, 6.4f, 6.5f, 6.9f], [4.65f, 5.3f, 6.3f, 6.7f, 7f]), + CreateScaling(190, [3.97f, 3.97f, 3.97f, 3.97f, 3.97f], [13.5f, 12f, 13f, 12.8f, 13.8f], [4.08f, 6.4f, 6.8f, 6.7f, 7.18f], [4.7f, 5.8f, 6.4f, 6.7f, 7.3f]), + CreateScaling(200, [4.01f, 4.01f, 4.01f, 4.01f, 4.01f], [15f, 15f, 15f, 14.8f, 14.8f], [4.28f, 6.8f, 7f, 6.9f, 7.28f], [4.85f, 6.4f, 7f, 7.1f, 7.5f]), + CreateScaling(210, [4.05f, 4.05f, 4.05f, 4.05f, 4.05f], [16f, 17f, 18f, 18.8f, 18.8f], [4.48f, 7f, 7.2f, 7.1f, 7.38f], [5f, 7f, 7.5f, 7.5f, 7.8f]), + CreateScaling(220, [5f, 4.09f, 4.09f, 4.09f, 4.09f], [17.8f, 18f, 19f, 19.8f, 20.6f], [4.68f, 7.01f, 6.8f, 6.58f, 7.58f], [6.4f, 7.2f, 7.7f, 7.7f, 7.9f]), + CreateScaling(230, [5.05f, 4.13f, 4.13f, 4.13f, 4.13f], [18.8f, 19f, 20f, 20.8f, 21.6f], [4.88f, 7.02f, 7f, 6.78f, 7.78f], [6.6f, 7.4f, 7.9f, 7.9f, 8.1f]), + CreateScaling(240, [5.1f, 4.17f, 4.17f, 4.17f, 4.17f], [19.8f, 20f, 21f, 21.8f, 22.6f], [5.08f, 7.03f, 7.1f, 6.98f, 7.98f], [6.8f, 7.6f, 8.1f, 8.1f, 8.3f]), + CreateScaling(250, [5.15f, 4.21f, 4.21f, 4.21f, 4.21f], [20.8f, 21f, 22f, 22.8f, 23.6f], [5.28f, 7.04f, 7.28f, 7.18f, 8.18f], [7.2f, 7.8f, 8.3f, 8.3f, 8.5f]), + CreateScaling(260, [5.2f, 4.25f, 4.25f, 4.25f, 4.25f], [21.8f, 22f, 23f, 23.8f, 24.6f], [5.48f, 7.05f, 7.48f, 7.38f, 8.38f], [7.6f, 8f, 8.5f, 8.5f, 8.7f]), + CreateScaling(270, [5.25f, 4.29f, 4.29f, 4.29f, 4.29f], [22.8f, 23f, 24f, 24.8f, 25.6f], [5.68f, 7.06f, 7.68f, 7.58f, 8.58f], [8f, 8.2f, 8.7f, 8.7f, 8.9f]), + CreateScaling(280, [5.3f, 4.33f, 4.33f, 4.33f, 4.33f], [23.8f, 24f, 25f, 25.8f, 26.6f], [5.88f, 7.07f, 7.88f, 7.78f, 8.78f], [8.2f, 8.4f, 8.9f, 8.9f, 9.1f]), + CreateScaling(290, [5.35f, 4.37f, 4.37f, 4.37f, 4.37f], [25.8f, 25f, 26f, 26.8f, 27.6f], [6.18f, 7.08f, 8.08f, 7.98f, 8.98f], [8.6f, 8.6f, 9.1f, 9.1f, 9.3f]), + CreateScaling(300, [5.4f, 4.41f, 4.41f, 4.41f, 4.41f], [26.2f, 26f, 27f, 27.8f, 28.6f], [6.28f, 7.28f, 8.28f, 8.18f, 9.18f], [8.7f, 8.8f, 9.3f, 9.3f, 9.5f]), + CreateScaling(310, [5.45f, 4.45f, 4.45f, 4.45f, 4.45f], [26.8f, 27f, 28f, 28.8f, 29.6f], [6.48f, 7.48f, 8.48f, 8.38f, 9.38f], [8.8f, 9f, 9.5f, 9.5f, 9.7f]), + CreateScaling(320, [5.5f, 4.49f, 4.49f, 4.49f, 4.49f], [27.8f, 28f, 29f, 29.8f, 30.6f], [6.68f, 7.68f, 8.68f, 8.58f, 9.58f], [9f, 9.2f, 9.7f, 9.7f, 9.9f]), + CreateScaling(330, [5.55f, 4.53f, 4.53f, 4.53f, 4.53f], [29.8f, 29.5f, 30f, 30.8f, 31.6f], [6.98f, 7.88f, 8.88f, 8.78f, 9.78f], [9.4f, 9.4f, 9.9f, 9.9f, 10.1f]), + CreateScaling(340, [5.6f, 4.57f, 4.57f, 4.57f, 4.57f], [30.8f, 30.5f, 31f, 31.8f, 32.6f], [7.18f, 8.08f, 9.08f, 9.05f, 10.05f], [9.6f, 9.6f, 10.1f, 10.1f, 10.3f]), + CreateScaling(350, [5.65f, 4.61f, 4.61f, 4.61f, 4.61f], [31.8f, 31.5f, 32f, 32.8f, 33.6f], [7.38f, 8.35f, 9.35f, 9.18f, 10.18f], [9.8f, 9.8f, 10.3f, 10.3f, 10.5f]), + CreateScaling(360, [5.7f, 4.65f, 4.65f, 4.65f, 4.65f], [32.8f, 32.5f, 33f, 33.8f, 34.6f], [7.58f, 8.48f, 9.48f, 9.38f, 10.38f], [10f, 10f, 10.5f, 10.5f, 10.7f]), + CreateScaling(370, [5.75f, 4.69f, 4.69f, 4.69f, 4.69f], [33.8f, 33.5f, 34f, 34.8f, 35.6f], [7.78f, 8.68f, 9.68f, 9.58f, 10.58f], [10.2f, 10.2f, 10.7f, 10.7f, 10.9f]), + CreateScaling(380, [5.8f, 4.73f, 4.73f, 4.73f, 4.73f], [34.8f, 34.5f, 35f, 35.8f, 36.6f], [7.98f, 8.88f, 9.88f, 9.78f, 10.78f], [10.4f, 10.4f, 10.9f, 10.9f, 11.1f]), + CreateScaling(390, [5.85f, 4.77f, 4.77f, 4.77f, 4.77f], [35.8f, 35.5f, 36f, 36.8f, 37.6f], [8.18f, 9.08f, 10.08f, 9.98f, 10.98f], [10.6f, 10.6f, 11.1f, 11.1f, 11.3f]), + CreateScaling(400, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [38f, 37f, 38f, 38.8f, 39.8f], [8.48f, 9.28f, 10.28f, 10.18f, 11.18f], [11.6f, 11.5f, 11.4f, 11.4f, 11.5f]), + CreateScaling(410, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [39f, 41f, 42f, 42.8f, 43.8f], [8.58f, 9.68f, 10.78f, 10.38f, 11.38f], [12f, 12.5f, 12.8f, 12.5f, 12.3f]), + CreateScaling(420, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [42f, 50f, 52f, 61.8f, 66.8f], [8.68f, 9.88f, 10.78f, 10.58f, 11.58f], [12.3f, 13.5f, 15f, 15.7f, 17.5f]), + CreateScaling(430, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [47f, 52f, 57f, 66.8f, 71.8f], [8.88f, 9.98f, 10.88f, 10.78f, 11.78f], [12.6f, 14f, 15.5f, 16.2f, 18f]), + CreateScaling(440, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [53f, 58f, 63f, 72.8f, 77.8f], [9.08f, 10.08f, 11.08f, 10.98f, 11.98f], [12.9f, 14.3f, 16.3f, 17f, 18.8f]), + CreateScaling(450, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [59f, 64f, 69f, 78.8f, 83.8f], [9.38f, 10.28f, 11.28f, 11.18f, 12.18f], [13.1f, 15.1f, 17.1f, 17.8f, 19.6f]), + CreateScaling(460, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [69f, 70f, 75f, 84.8f, 89.8f], [9.98f, 11f, 12f, 12.38f, 13.38f], [14f, 15.9f, 17.9f, 18.6f, 20.4f]), + CreateScaling(470, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [76f, 76f, 81f, 90.8f, 95.8f], [10.28f, 12f, 13f, 13.1f, 13.7f], [14.7f, 16.7f, 18.7f, 19.4f, 21.2f]), + CreateScaling(480, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [82f, 82f, 87f, 96.8f, 100.8f], [10.58f, 12.5f, 13.5f, 13.5f, 14f], [15.2f, 17.5f, 19.5f, 20.2f, 22f]), + CreateScaling(490, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [88f, 88f, 93f, 101.8f, 106.8f], [10.88f, 13f, 13.8f, 13.7f, 14.3f], [16f, 18.3f, 20.3f, 21f, 22.8f]), + CreateScaling(500, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [93f, 94f, 99f, 107.8f, 112.8f], [11.18f, 13.4f, 14.1f, 14f, 14.6f], [16.7f, 19.1f, 21.1f, 21.8f, 23.6f]), + CreateScaling(510, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [95f, 100f, 105f, 113.8f, 118.8f], [11.38f, 13.7f, 14.4f, 14.3f, 14.9f], [17.4f, 19.9f, 21.9f, 22.6f, 24.4f]), + CreateScaling(520, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [101f, 106f, 111f, 119.8f, 124.8f], [11.48f, 14f, 14.7f, 14.6f, 15.2f], [17.7f, 20.7f, 22.7f, 23.4f, 25.2f]), + CreateScaling(530, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [107f, 112f, 117f, 125.8f, 130.8f], [11.78f, 14.3f, 15f, 14.9f, 15.5f], [18.5f, 21.5f, 23.5f, 24.2f, 26f]), + CreateScaling(540, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [113f, 118f, 123f, 131.8f, 136.8f], [12.08f, 14.6f, 15.3f, 15.2f, 15.8f], [19.3f, 22.3f, 24.3f, 25f, 26.8f]), + CreateScaling(550, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [119f, 124f, 129f, 137.8f, 142.8f], [12.38f, 14.9f, 15.6f, 15.5f, 16.1f], [20.1f, 23.1f, 25.1f, 25.8f, 27.6f]), + CreateScaling(560, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [125f, 130f, 135f, 143.8f, 148.8f], [12.68f, 15.2f, 15.9f, 15.8f, 16.4f], [20.9f, 23.9f, 25.9f, 26.6f, 28.4f]), + CreateScaling(570, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [131f, 136f, 141f, 149.8f, 154.8f], [12.98f, 15.5f, 16.2f, 16.1f, 16.7f], [21.7f, 24.7f, 26.7f, 27.4f, 29.2f]), + CreateScaling(580, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [137f, 142f, 147f, 155.8f, 160.8f], [13.28f, 15.8f, 16.5f, 16.4f, 17f], [22.5f, 25.5f, 27.5f, 28.2f, 30f]), + CreateScaling(590, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [143f, 148f, 153f, 161.8f, 166.8f], [13.58f, 16.1f, 16.8f, 16.7f, 17.3f], [23.3f, 26.3f, 28.3f, 29f, 30.8f]), + CreateScaling(600, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [149f, 157f, 162f, 167.8f, 172.8f], [13.88f, 16.6f, 17.2f, 17f, 17.6f], [24.1f, 28.1f, 29.1f, 29.8f, 31.6f]), + CreateScaling(610, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [39f, 41f, 42f, 42.8f, 43.8f], [8.58f, 9.68f, 10.78f, 10.38f, 11.38f], [12f, 12.5f, 12.8f, 12.5f, 12.3f]), + CreateScaling(620, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [42f, 50f, 52f, 61.8f, 66.8f], [8.68f, 9.88f, 10.78f, 10.58f, 11.58f], [12.3f, 13.5f, 15f, 15.7f, 17.5f]), + CreateScaling(630, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [47f, 52f, 57f, 66.8f, 71.8f], [8.88f, 9.98f, 10.88f, 10.78f, 11.78f], [12.6f, 14f, 15.5f, 16.2f, 18f]), + CreateScaling(640, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [53f, 58f, 63f, 72.8f, 77.8f], [9.08f, 10.08f, 11.08f, 10.98f, 11.98f], [12.9f, 14.3f, 16.3f, 17f, 18.8f]), + CreateScaling(650, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [59f, 64f, 69f, 78.8f, 83.8f], [9.38f, 10.28f, 11.28f, 11.18f, 12.18f], [13.1f, 15.1f, 17.1f, 17.8f, 19.6f]), + CreateScaling(660, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [69f, 70f, 75f, 84.8f, 89.8f], [9.98f, 11f, 12f, 12.38f, 13.38f], [14f, 15.9f, 17.9f, 18.6f, 20.4f]), + CreateScaling(670, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [76f, 76f, 81f, 90.8f, 95.8f], [10.28f, 12f, 13f, 13.1f, 13.7f], [14.7f, 16.7f, 18.7f, 19.4f, 21.2f]), + CreateScaling(680, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [82f, 82f, 87f, 96.8f, 100.8f], [10.58f, 12.5f, 13.5f, 13.5f, 14f], [15.2f, 17.5f, 19.5f, 20.2f, 22f]), + CreateScaling(690, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [88f, 88f, 93f, 101.8f, 106.8f], [10.88f, 13f, 13.8f, 13.7f, 14.3f], [16f, 18.3f, 20.3f, 21f, 22.8f]), + CreateScaling(700, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [93f, 94f, 99f, 107.8f, 112.8f], [11.18f, 13.4f, 14.1f, 14f, 14.6f], [16.7f, 19.1f, 21.1f, 21.8f, 23.6f]), + CreateScaling(710, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [95f, 100f, 105f, 113.8f, 118.8f], [11.38f, 13.7f, 14.4f, 14.3f, 14.9f], [17.4f, 19.9f, 21.9f, 22.6f, 24.4f]), + CreateScaling(720, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [101f, 106f, 111f, 119.8f, 124.8f], [11.48f, 14f, 14.7f, 14.6f, 15.2f], [17.7f, 20.7f, 22.7f, 23.4f, 25.2f]), + CreateScaling(730, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [107f, 112f, 117f, 125.8f, 130.8f], [11.78f, 14.3f, 15f, 14.9f, 15.5f], [18.5f, 21.5f, 23.5f, 24.2f, 26f]), + CreateScaling(740, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [113f, 118f, 123f, 131.8f, 136.8f], [12.08f, 14.6f, 15.3f, 15.2f, 15.8f], [19.3f, 22.3f, 24.3f, 25f, 26.8f]), + CreateScaling(750, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [119f, 124f, 129f, 137.8f, 142.8f], [12.38f, 14.9f, 15.6f, 15.5f, 16.1f], [20.1f, 23.1f, 25.1f, 25.8f, 27.6f]), + CreateScaling(760, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [125f, 130f, 135f, 143.8f, 148.8f], [12.68f, 15.2f, 15.9f, 15.8f, 16.4f], [20.9f, 23.9f, 25.9f, 26.6f, 28.4f]), + CreateScaling(770, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [131f, 136f, 141f, 149.8f, 154.8f], [12.98f, 15.5f, 16.2f, 16.1f, 16.7f], [21.7f, 24.7f, 26.7f, 27.4f, 29.2f]), + CreateScaling(780, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [137f, 142f, 147f, 155.8f, 160.8f], [13.28f, 15.8f, 16.5f, 16.4f, 17f], [22.5f, 25.5f, 27.5f, 28.2f, 30f]), + CreateScaling(790, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [143f, 148f, 153f, 161.8f, 166.8f], [13.58f, 16.1f, 16.8f, 16.7f, 17.3f], [23.3f, 26.3f, 28.3f, 29f, 30.8f]), + CreateScaling(800, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [149f, 157f, 162f, 167.8f, 172.8f], [13.88f, 16.6f, 17.2f, 17f, 17.6f], [24.1f, 28.1f, 29.1f, 29.8f, 31.6f]), + ]; + } + + private static DoppelgangerMonsterScaling CreateScaling(int maximumPlayerLevel, float[] level, float[] health, float[] damage, float[] defense) + { + return new DoppelgangerMonsterScaling + { + MaximumPlayerLevel = maximumPlayerLevel, + LevelMultipliers = level, + HealthMultipliers = health, + DamageMultipliers = damage, + DefenseMultipliers = defense, + }; + } +} diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs index 525b61cf7..34554ffb7 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs @@ -9,7 +9,7 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; /// /// Describes the run of the doppelganger event. /// -public class DoppelgangerEventDefinition +public partial class DoppelgangerEventDefinition { /// /// Gets or sets the number of monsters which may reach the magic circle until the event fails. @@ -81,6 +81,18 @@ public class DoppelgangerEventDefinition /// 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 numbers of the monsters which leave interim reward chests behind when they die. /// @@ -150,10 +162,24 @@ public static DoppelgangerEventDefinition CreateDefault() new DoppelgangerMonsterSpawn { SpawnTime = TimeSpan.FromMinutes(4), MonsterNumbers = [madButcher, doppelgangerDarkLord] }, new DoppelgangerMonsterSpawn { SpawnTime = TimeSpan.FromMinutes(7), MonsterNumbers = [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. /// diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterScaling.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterScaling.cs new file mode 100644 index 000000000..c26c6bb58 --- /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/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs index 461448c5d..e16ce531c 100644 --- a/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs +++ b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs @@ -72,6 +72,40 @@ 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 ten 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, 10)] + [TestCase(10, 10)] + [TestCase(11, 20)] + [TestCase(400, 400)] + [TestCase(401, 410)] + [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(80)); + 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 there is no position on a map without a path. /// diff --git a/tests/MUnique.OpenMU.Tests/MonsterInstanceMultiplierTest.cs b/tests/MUnique.OpenMU.Tests/MonsterInstanceMultiplierTest.cs new file mode 100644 index 000000000..2e508f3d9 --- /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); + } +} From bc75796445f7d7a47a8a1ffde2383c004860a060 Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:51:27 -0300 Subject: [PATCH 10/18] feat(doppelganger): announce the ice walker mission Like the original server, the players get a notice when the ice walkers appear, when all of them were killed and when they escaped, so that the players know why the following monsters got stronger. The messages are localizable resources of the PlayerMessage. Co-Authored-By: Claude Opus 5.5 --- .../Doppelganger/DoppelgangerContext.cs | 3 +++ .../Properties/PlayerMessage.Designer.cs | 27 +++++++++++++++++++ src/GameLogic/Properties/PlayerMessage.resx | 9 +++++++ 3 files changed, 39 insertions(+) diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs index cbb06b985..02b36aaf2 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs @@ -249,6 +249,7 @@ protected override async void OnMonsterDied(object? sender, DeathInformation e) 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); } } @@ -495,6 +496,7 @@ private async Task RunIceWalkerMissionAsync(CancellationToken cancellationToken) } 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 @@ -523,6 +525,7 @@ private async Task RunIceWalkerMissionAsync(CancellationToken cancellationToken) } await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.HideIceWalkerAsync()).AsTask()).ConfigureAwait(false); + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.DoppelgangerIceWalkerEscaped)).ConfigureAwait(false); } catch (OperationCanceledException) { diff --git a/src/GameLogic/Properties/PlayerMessage.Designer.cs b/src/GameLogic/Properties/PlayerMessage.Designer.cs index a2794020b..4f58fb3a2 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 4d7119c15..d04c613c4 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! + From 911beccdacde5f71f3e2cf24bfb4296b7b8516be Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:11:07 -0300 Subject: [PATCH 11/18] feat(doppelganger): accept the free ticket and transform signs into mirrors - The event accepts a Mirror of Dimensions (14,111) and a Doppelganger Free Ticket (13,125). MiniGameDefinition supports only one ticket item, so the definitions don't define one anymore, and the EnterDoppelgangerAction checks and consumes the ticket after a successful entrance. A free ticket loses one durability per use. - The Sign of Dimensions (14,110) drops from monsters of level 32 and above with a chance of 0.1%. It stacks up to five pieces, which transform into a Mirror of Dimensions, like the Symbol of Kundun transforms into a Lost Map. - The update plug-in adds the new items, the drop group to all maps and removes the ticket item from the definitions of existing databases. Co-Authored-By: Claude Opus 5.5 --- .../MiniGames/EnterDoppelgangerAction.cs | 60 ++++++++++++++++++- .../PlugIns/SignOfDimensionsStackedPlugIn.cs | 44 ++++++++++++++ .../AddDoppelgangerDataUpdatePlugIn.cs | 16 ++++- .../Events/DoppelgangerInitializer.cs | 5 +- .../Items/EventTicketItems.cs | 40 +++++++++++-- .../DoppelgangerDataTest.cs | 24 +++++++- .../SignOfDimensionsStackedPlugInTest.cs | 60 +++++++++++++++++++ 7 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 src/GameLogic/PlugIns/SignOfDimensionsStackedPlugIn.cs create mode 100644 tests/MUnique.OpenMU.Tests/SignOfDimensionsStackedPlugInTest.cs diff --git a/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs b/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs index ddd555931..9630ea6cf 100644 --- a/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs +++ b/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs @@ -5,6 +5,8 @@ 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. @@ -13,9 +15,19 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.MiniGames; /// 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(); /// @@ -28,7 +40,7 @@ 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) + if (definitions.Count == 0 || FindTicket(player, ticketInventoryIndex) is not { } ticket) { await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(MiniGameType.Doppelganger, EnterResult.Failed)).ConfigureAwait(false); return; @@ -38,5 +50,51 @@ public async ValueTask TryEnterAsync(Player player, byte ticketInventoryIndex) ?? 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); + } + } + + private 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/SignOfDimensionsStackedPlugIn.cs b/src/GameLogic/PlugIns/SignOfDimensionsStackedPlugIn.cs new file mode 100644 index 000000000..5f8878138 --- /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(SignOfDimensionsStackedPlugIn), Description = "This plugin transforms a full stack of signs of dimensions into a mirror of dimensions.")] +[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/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs index 0b5058fd5..6fa8feb28 100644 --- a/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs +++ b/src/Persistence/Initialization/Updates/AddDoppelgangerDataUpdatePlugIn.cs @@ -15,7 +15,8 @@ namespace MUnique.OpenMU.Persistence.Initialization.Updates; /// Adds the doppelganger event configuration to an existing Season 6 database. /// /// -/// It adds the Mirror of Dimensions as entrance ticket, the monsters, the reward chests and the s, +/// 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 @@ -59,9 +60,12 @@ public class AddDoppelgangerDataUpdatePlugIn : UpdatePlugInBase /// protected override ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration) { - if (!gameConfiguration.Items.Any(item => item is { Group: 14, Number: 111 })) + if (new EventTicketItems(context, gameConfiguration).CreateDoppelgangerItems() is { } signOfDimensionsDropGroup) { - new EventTicketItems(context, gameConfiguration).CreateDoppelgangerItems(); + foreach (var map in gameConfiguration.Maps) + { + map.DropItemGroups.Add(signOfDimensionsDropGroup); + } } if (gameConfiguration.Monsters.FirstOrDefault(monster => monster.Number == LugardNumber) is { } lugard) @@ -93,6 +97,12 @@ protected override ValueTask ApplyAsync(IContext context, GameConfiguration game 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; } diff --git a/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerInitializer.cs index 13918936b..fb6d3c525 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerInitializer.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerInitializer.cs @@ -14,6 +14,8 @@ namespace MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Events; /// 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 { @@ -37,7 +39,6 @@ public override void Initialize() new DoppelgangerMonsters(this.Context, this.GameConfiguration).Initialize(); } - var ticket = this.GameConfiguration.Items.Single(item => item is { Group: 14, Number: 111 }); for (var i = 0; i < MapNumbers.Length; i++) { var gameLevel = i + 1; @@ -56,7 +57,7 @@ public override void Initialize() 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 = ticket; + doppelganger.TicketItem = null; doppelganger.GameLevel = (byte)gameLevel; doppelganger.MapCreationPolicy = MiniGameMapCreationPolicy.OnePerParty; doppelganger.SaveRankingStatistics = false; diff --git a/src/Persistence/Initialization/VersionSeasonSix/Items/EventTicketItems.cs b/src/Persistence/Initialization/VersionSeasonSix/Items/EventTicketItems.cs index 32230c381..19e1607e4 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Items/EventTicketItems.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Items/EventTicketItems.cs @@ -40,7 +40,10 @@ public override void Initialize() this.CreateEventItem(51, 13, 2, 2, "Scroll of Blood", false, 6); // Doppelganger: - this.CreateDoppelgangerItems(); + if (this.CreateDoppelgangerItems() is { } signOfDimensionsDropGroup) + { + BaseMapInitializer.RegisterDefaultDropItemGroup(signOfDimensionsDropGroup); + } // Devil Square: this.CreateEventItem(17, 14, 1, 1, "Devil's Eye", false, 7, 2, 36, 47, 60, 70, 80, 90); @@ -61,11 +64,40 @@ public override void Initialize() } /// - /// Creates the items of the doppelganger event. + /// 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. /// - internal void CreateDoppelgangerItems() + /// The drop item group of the Sign of Dimensions, if it was created. + internal DropItemGroup? CreateDoppelgangerItems() { - this.CreateEventItem(111, 14, 1, 1, "Mirror of Dimensions", false); + 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) diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs index 70634d319..7fa68a5a1 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs @@ -51,6 +51,15 @@ public async Task UpdateAddsEventToExistingDatabaseAsync() } 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); @@ -91,6 +100,12 @@ public async Task UpdateAddsMissingMonstersAsync() 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); @@ -108,13 +123,18 @@ private static async Task CreateSeason6ConfigurationAsync() private static void AssertEventData(GameConfiguration gameConfiguration) { - var ticket = gameConfiguration.Items.Single(item => item is { Group: 14, Number: 111 }); + 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 == ticket)); + 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))); diff --git a/tests/MUnique.OpenMU.Tests/SignOfDimensionsStackedPlugInTest.cs b/tests/MUnique.OpenMU.Tests/SignOfDimensionsStackedPlugInTest.cs new file mode 100644 index 000000000..2368e4bc5 --- /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); + } +} From 8cee2bdf37951a2ad53da1abd4c7bc576232fc57 Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:05:16 -0300 Subject: [PATCH 12/18] fix(doppelganger): address review findings - The plug-ins of the reward chests and the sign of dimensions use localized names and descriptions of the PlugInResources, like their siblings. - The separator lines of the new PlayerMessage properties match the format of the resource code generator. - An opened chest is removed after awaiting its opening animation, instead of a fire-and-forget task without cancellation. - Removed a redundant assignment of the monster to its intelligence, which the constructor of the monster already does. - The shown goal count doesn't exceed its maximum, when several monsters reach the magic circle at the same time. - The default multipliers of the monsters end at a player level of 600. The entries above repeated the ones of 410 to 600, so monsters of higher level players got weaker. Co-Authored-By: Claude Opus 5.5 --- .../Doppelganger/DoppelgangerContext.cs | 23 ++++-------- ...elgangerEventDefinition.MonsterScalings.cs | 21 +---------- .../PlugIns/DoppelgangerRewardChestPlugIn.cs | 2 +- .../PlugIns/SignOfDimensionsStackedPlugIn.cs | 2 +- .../Properties/PlayerMessage.Designer.cs | 6 ++-- .../Properties/PlugInResources.Designer.cs | 36 +++++++++++++++++++ src/GameLogic/Properties/PlugInResources.resx | 12 +++++++ .../DoppelgangerEventDefinitionTest.cs | 7 ++-- 8 files changed, 65 insertions(+), 44 deletions(-) diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs index 02b36aaf2..a13cc1949 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs @@ -130,8 +130,11 @@ public async ValueTask RegisterMonsterReachedMagicCircleAsync() } 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(goalCount, this._definition.MaximumGoalCount)).AsTask()).ConfigureAwait(false); + p.ShowMonsterGoalAsync(shownGoalCount, this._definition.MaximumGoalCount)).AsTask()).ConfigureAwait(false); if (goalCount == this._definition.MaximumGoalCount) { @@ -204,7 +207,9 @@ public async ValueTask OpenRewardChestAsync(Player player, Destructible chest) await this.DropChestItemsAsync(player, chest).ConfigureAwait(false); } - _ = Task.Run(() => this.RemoveOpenedChestAsync(chest)); + // 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); } /// @@ -553,7 +558,6 @@ private async Task RunIceWalkerMissionAsync(CancellationToken cancellationToken) var intelligence = new DoppelgangerMonsterIntelligence(this._path, pathPosition, walksAlongPath, attacksFirst, this.OnMonsterReachedMagicCircleAsync, this.Logger); var monster = new Monster(spawnArea, monsterDefinition, this.Map, this.DropGenerator, intelligence, this._gameContext.PlugInManager, this._gameContext.PathFinderPool, this); - intelligence.Npc = monster; // The multipliers have to be applied before the monster is initialized, which sets its health. var penalty = isAffectedByMissionFailure && Volatile.Read(ref this._isIceWalkerMissionFailed) != 0 @@ -634,19 +638,6 @@ private async ValueTask DropChestItemsAsync(Player player, Destructible chest) } } - private async Task RemoveOpenedChestAsync(Destructible chest) - { - try - { - await Task.Delay(OpenedChestRemovalDelay).ConfigureAwait(false); - await this.RemoveNpcAsync(chest).ConfigureAwait(false); - } - catch (Exception ex) - { - this.Logger.LogDebug(ex, "{context}: Failed to remove the opened chest {chest}.", this, chest); - } - } - private async ValueTask SpawnDestructibleAsync(short number, DoppelgangerPathArea area) { if (this.CreateSpawnArea(number, area) is not { } spawnArea) diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs index 62dbeea6b..843316caa 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs @@ -13,6 +13,7 @@ public partial class DoppelgangerEventDefinition /// Creates the multipliers of the monsters of the original season 6 event. Each entry applies /// up to a player level (including the master level) and contains the multipliers for the /// level, health, damage and defense for one to five players. + /// Higher player levels use the last entry. /// private static IList CreateDefaultMonsterScalings() { @@ -78,26 +79,6 @@ private static IList CreateDefaultMonsterScalings() CreateScaling(580, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [137f, 142f, 147f, 155.8f, 160.8f], [13.28f, 15.8f, 16.5f, 16.4f, 17f], [22.5f, 25.5f, 27.5f, 28.2f, 30f]), CreateScaling(590, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [143f, 148f, 153f, 161.8f, 166.8f], [13.58f, 16.1f, 16.8f, 16.7f, 17.3f], [23.3f, 26.3f, 28.3f, 29f, 30.8f]), CreateScaling(600, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [149f, 157f, 162f, 167.8f, 172.8f], [13.88f, 16.6f, 17.2f, 17f, 17.6f], [24.1f, 28.1f, 29.1f, 29.8f, 31.6f]), - CreateScaling(610, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [39f, 41f, 42f, 42.8f, 43.8f], [8.58f, 9.68f, 10.78f, 10.38f, 11.38f], [12f, 12.5f, 12.8f, 12.5f, 12.3f]), - CreateScaling(620, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [42f, 50f, 52f, 61.8f, 66.8f], [8.68f, 9.88f, 10.78f, 10.58f, 11.58f], [12.3f, 13.5f, 15f, 15.7f, 17.5f]), - CreateScaling(630, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [47f, 52f, 57f, 66.8f, 71.8f], [8.88f, 9.98f, 10.88f, 10.78f, 11.78f], [12.6f, 14f, 15.5f, 16.2f, 18f]), - CreateScaling(640, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [53f, 58f, 63f, 72.8f, 77.8f], [9.08f, 10.08f, 11.08f, 10.98f, 11.98f], [12.9f, 14.3f, 16.3f, 17f, 18.8f]), - CreateScaling(650, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [59f, 64f, 69f, 78.8f, 83.8f], [9.38f, 10.28f, 11.28f, 11.18f, 12.18f], [13.1f, 15.1f, 17.1f, 17.8f, 19.6f]), - CreateScaling(660, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [69f, 70f, 75f, 84.8f, 89.8f], [9.98f, 11f, 12f, 12.38f, 13.38f], [14f, 15.9f, 17.9f, 18.6f, 20.4f]), - CreateScaling(670, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [76f, 76f, 81f, 90.8f, 95.8f], [10.28f, 12f, 13f, 13.1f, 13.7f], [14.7f, 16.7f, 18.7f, 19.4f, 21.2f]), - CreateScaling(680, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [82f, 82f, 87f, 96.8f, 100.8f], [10.58f, 12.5f, 13.5f, 13.5f, 14f], [15.2f, 17.5f, 19.5f, 20.2f, 22f]), - CreateScaling(690, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [88f, 88f, 93f, 101.8f, 106.8f], [10.88f, 13f, 13.8f, 13.7f, 14.3f], [16f, 18.3f, 20.3f, 21f, 22.8f]), - CreateScaling(700, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [93f, 94f, 99f, 107.8f, 112.8f], [11.18f, 13.4f, 14.1f, 14f, 14.6f], [16.7f, 19.1f, 21.1f, 21.8f, 23.6f]), - CreateScaling(710, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [95f, 100f, 105f, 113.8f, 118.8f], [11.38f, 13.7f, 14.4f, 14.3f, 14.9f], [17.4f, 19.9f, 21.9f, 22.6f, 24.4f]), - CreateScaling(720, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [101f, 106f, 111f, 119.8f, 124.8f], [11.48f, 14f, 14.7f, 14.6f, 15.2f], [17.7f, 20.7f, 22.7f, 23.4f, 25.2f]), - CreateScaling(730, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [107f, 112f, 117f, 125.8f, 130.8f], [11.78f, 14.3f, 15f, 14.9f, 15.5f], [18.5f, 21.5f, 23.5f, 24.2f, 26f]), - CreateScaling(740, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [113f, 118f, 123f, 131.8f, 136.8f], [12.08f, 14.6f, 15.3f, 15.2f, 15.8f], [19.3f, 22.3f, 24.3f, 25f, 26.8f]), - CreateScaling(750, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [119f, 124f, 129f, 137.8f, 142.8f], [12.38f, 14.9f, 15.6f, 15.5f, 16.1f], [20.1f, 23.1f, 25.1f, 25.8f, 27.6f]), - CreateScaling(760, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [125f, 130f, 135f, 143.8f, 148.8f], [12.68f, 15.2f, 15.9f, 15.8f, 16.4f], [20.9f, 23.9f, 25.9f, 26.6f, 28.4f]), - CreateScaling(770, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [131f, 136f, 141f, 149.8f, 154.8f], [12.98f, 15.5f, 16.2f, 16.1f, 16.7f], [21.7f, 24.7f, 26.7f, 27.4f, 29.2f]), - CreateScaling(780, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [137f, 142f, 147f, 155.8f, 160.8f], [13.28f, 15.8f, 16.5f, 16.4f, 17f], [22.5f, 25.5f, 27.5f, 28.2f, 30f]), - CreateScaling(790, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [143f, 148f, 153f, 161.8f, 166.8f], [13.58f, 16.1f, 16.8f, 16.7f, 17.3f], [23.3f, 26.3f, 28.3f, 29f, 30.8f]), - CreateScaling(800, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [149f, 157f, 162f, 167.8f, 172.8f], [13.88f, 16.6f, 17.2f, 17f, 17.6f], [24.1f, 28.1f, 29.1f, 29.8f, 31.6f]), ]; } diff --git a/src/GameLogic/PlugIns/DoppelgangerRewardChestPlugIn.cs b/src/GameLogic/PlugIns/DoppelgangerRewardChestPlugIn.cs index 0460c132b..2f5d77e56 100644 --- a/src/GameLogic/PlugIns/DoppelgangerRewardChestPlugIn.cs +++ b/src/GameLogic/PlugIns/DoppelgangerRewardChestPlugIn.cs @@ -15,7 +15,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns; /// [Guid("3E9A6C51-7B28-4F0D-A1C4-58D2E96B7F30")] [PlugIn] -[Display(Name = nameof(DoppelgangerRewardChestPlugIn), Description = "Opens the reward chests of the doppelganger event.")] +[Display(Name = nameof(PlugInResources.DoppelgangerRewardChestPlugIn_Name), Description = nameof(PlugInResources.DoppelgangerRewardChestPlugIn_Description), ResourceType = typeof(PlugInResources))] public class DoppelgangerRewardChestPlugIn : IPlayerTalkToNpcPlugIn { /// diff --git a/src/GameLogic/PlugIns/SignOfDimensionsStackedPlugIn.cs b/src/GameLogic/PlugIns/SignOfDimensionsStackedPlugIn.cs index 5f8878138..db1084f15 100644 --- a/src/GameLogic/PlugIns/SignOfDimensionsStackedPlugIn.cs +++ b/src/GameLogic/PlugIns/SignOfDimensionsStackedPlugIn.cs @@ -12,7 +12,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns; /// which is the ticket of the doppelganger event. /// [PlugIn] -[Display(Name = nameof(SignOfDimensionsStackedPlugIn), Description = "This plugin transforms a full stack of signs of dimensions into a mirror of dimensions.")] +[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 { diff --git a/src/GameLogic/Properties/PlayerMessage.Designer.cs b/src/GameLogic/Properties/PlayerMessage.Designer.cs index 4f58fb3a2..49588da0b 100644 --- a/src/GameLogic/Properties/PlayerMessage.Designer.cs +++ b/src/GameLogic/Properties/PlayerMessage.Designer.cs @@ -185,7 +185,7 @@ public static string DoppelgangerIceWalkerAppeared { return ResourceManager.GetString("DoppelgangerIceWalkerAppeared", resourceCulture); } } - + /// /// Looks up a localized string similar to The Ice Walker escaped! The following monsters are stronger.. /// @@ -194,7 +194,7 @@ public static string DoppelgangerIceWalkerEscaped { return ResourceManager.GetString("DoppelgangerIceWalkerEscaped", resourceCulture); } } - + /// /// Looks up a localized string similar to The Ice Walker has been defeated!. /// @@ -203,7 +203,7 @@ public static string DoppelgangerIceWalkerKilled { 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/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 0c3f22ff6..ac21c839f 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -609,6 +609,24 @@ public static string DisconnectChatCommandPlugIn_Name { } } + /// + /// 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 +2717,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 7f4b93a4d..8ae3f4c1f 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1284,4 +1284,16 @@ 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. + diff --git a/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs index e16ce531c..922da8975 100644 --- a/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs +++ b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs @@ -83,8 +83,9 @@ public void HerdBaseCount(int elapsedSeconds, int expectedCount) [TestCase(11, 20)] [TestCase(400, 400)] [TestCase(401, 410)] - [TestCase(800, 800)] - [TestCase(1000, 800)] + [TestCase(600, 600)] + [TestCase(601, 600)] + [TestCase(800, 600)] public void MonsterScalingByPlayerLevel(int playerLevel, int expectedMaximumPlayerLevel) { Assert.That(this._definition.GetMonsterScaling(playerLevel)?.MaximumPlayerLevel, Is.EqualTo(expectedMaximumPlayerLevel)); @@ -96,7 +97,7 @@ public void MonsterScalingByPlayerLevel(int playerLevel, int expectedMaximumPlay [Test] public void DefaultMonsterScalingsAreComplete() { - Assert.That(this._definition.MonsterScalings, Has.Count.EqualTo(80)); + Assert.That(this._definition.MonsterScalings, Has.Count.EqualTo(60)); Assert.That( this._definition.MonsterScalings, Has.All.Matches(scaling => From 36627f3a8107d29e1f2aae1ac34440161dd0f4fa Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:34:55 -0300 Subject: [PATCH 13/18] feat(doppelganger): configure the event in the admin panel The run of the event is described by the DoppelgangerEventDefinition, which is now the configuration of the DoppelgangerFeaturePlugIn, like the event definition of Kanturu. It can therefore be adapted in the admin panel, e.g. the monsters of the herds and their multipliers, the paths and the chests. - The monsters are referenced by their definition instead of their number. - The path areas have settable properties, so they can be edited. - A new database gets the default configuration; for existing databases the context falls back to the default definition, until an administrator configures it. - The default multipliers are simple values which grow linearly with the player level, meant to be adjusted by the server administrator. Co-Authored-By: Claude Opus 5.5 --- .../Doppelganger/DoppelgangerContext.cs | 68 ++++++----- ...elgangerEventDefinition.MonsterScalings.cs | 110 +++++------------- .../DoppelgangerEventDefinition.cs | 64 ++++++---- .../Doppelganger/DoppelgangerFeaturePlugIn.cs | 27 +++++ .../DoppelgangerMonsterIntelligence.cs | 2 +- .../Doppelganger/DoppelgangerMonsterSpawn.cs | 6 +- .../Doppelganger/DoppelgangerPathArea.cs | 57 +++++++-- .../Properties/PlugInResources.Designer.cs | 18 +++ src/GameLogic/Properties/PlugInResources.resx | 6 + .../Initialization/DataInitializationBase.cs | 8 ++ .../DoppelgangerDataTest.cs | 20 ++++ .../DoppelgangerEventDefinitionTest.cs | 52 +++++++-- 12 files changed, 287 insertions(+), 151 deletions(-) create mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerFeaturePlugIn.cs diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs index a13cc1949..a219ccba0 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs @@ -98,7 +98,9 @@ public DoppelgangerContext( : base(key, definition, gameContext, mapInitializer) { this._gameContext = gameContext; - this._definition = DoppelgangerEventDefinition.CreateDefault(); + + // 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 ?? []; } @@ -199,7 +201,7 @@ public async ValueTask OpenRewardChestAsync(Player player, Destructible chest) var area = GetAreaAround(chest.Position, 2); for (var i = 0; i < Math.Max(1, this._initialPlayerCount); i++) { - await this.SpawnMonsterAsync(this._definition.LarvaNumber, area, 0, false, true).ConfigureAwait(false); + await this.SpawnMonsterAsync(this._definition.Larva, area, 0, false, true).ConfigureAwait(false); } } else @@ -246,7 +248,7 @@ protected override async void OnMonsterDied(object? sender, DeathInformation e) } this._pathMonsters.TryRemove(monster, out _); - if (this.State == MiniGameState.Playing && this._definition.InterimChestMonsterNumbers.Contains(monster.Definition.Number)) + if (this.State == MiniGameState.Playing && this._definition.InterimChestMonsters.Any(m => IsSameMonster(m, monster.Definition))) { await this.SpawnInterimChestsAsync(monster.Position).ConfigureAwait(false); } @@ -321,12 +323,28 @@ await player.InvokeViewPlugInAsync(async p => if (result == DoppelgangerResult.Success && finishers.FirstOrDefault(player => player.IsAlive) is { } firstFinisher) { - this._finalChest = await this.SpawnDestructibleAsync(this._definition.FinalRewardChestNumber, GetAreaAround(firstFinisher.Position, 1)).ConfigureAwait(false); + 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. @@ -436,9 +454,9 @@ private async Task RunMonsterSpawnsAsync(CancellationToken cancellationToken) while (pendingSpawns.Count > 0 && pendingSpawns[0].SpawnTime <= elapsed) { - foreach (var monsterNumber in pendingSpawns[0].MonsterNumbers) + foreach (var monster in pendingSpawns[0].Monsters) { - await this.SpawnMonsterAsync(monsterNumber, 0, true, true, true).ConfigureAwait(false); + await this.SpawnMonsterAsync(monster, 0, true, true, true).ConfigureAwait(false); } pendingSpawns.RemoveAt(0); @@ -464,7 +482,7 @@ private async Task RunMonsterSpawnsAsync(CancellationToken cancellationToken) private async ValueTask SpawnHerdAsync(TimeSpan elapsed) { - if (this._definition.HerdMonsterNumbers.Count == 0) + if (this._definition.HerdMonsters.Count == 0) { return; } @@ -472,10 +490,10 @@ private async ValueTask SpawnHerdAsync(TimeSpan elapsed) var count = this._definition.GetHerdBaseCount(elapsed) + Math.Max(0, this.PlayerCount - 1); for (var i = 0; i < count; i++) { - var monsterNumber = this._definition.HerdMonsterNumbers[Rand.NextInt(0, this._definition.HerdMonsterNumbers.Count)]; - var attacksFirst = this._definition.AlwaysAttackingMonsterNumbers.Contains(monsterNumber) + 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(monsterNumber, 0, true, attacksFirst, true).ConfigureAwait(false); + await this.SpawnMonsterAsync(monster, 0, true, attacksFirst, true).ConfigureAwait(false); } } @@ -488,7 +506,7 @@ private async Task RunIceWalkerMissionAsync(CancellationToken cancellationToken) for (var i = 0; i < count; i++) { var position = Rand.NextInt(this._definition.IceWalkerMinimumPosition, this._definition.IceWalkerMaximumPosition + 1); - if (await this.SpawnMonsterAsync(this._definition.IceWalkerNumber, position, false, true).ConfigureAwait(false) is { } iceWalker) + if (await this.SpawnMonsterAsync(this._definition.IceWalker, position, false, true).ConfigureAwait(false) is { } iceWalker) { this._iceWalkers.TryAdd(iceWalker, 0); shownPosition = shownPosition < 0 ? position : shownPosition; @@ -542,22 +560,20 @@ private async Task RunIceWalkerMissionAsync(CancellationToken cancellationToken) } } - private ValueTask SpawnMonsterAsync(short monsterNumber, int pathPosition, bool walksAlongPath, bool attacksFirst, bool isAffectedByMissionFailure = false) + private ValueTask SpawnMonsterAsync(MonsterDefinition? monsterDefinition, int pathPosition, bool walksAlongPath, bool attacksFirst, bool isAffectedByMissionFailure = false) { - return this.SpawnMonsterAsync(monsterNumber, this._path[pathPosition], pathPosition, walksAlongPath, attacksFirst, isAffectedByMissionFailure); + return this.SpawnMonsterAsync(monsterDefinition, this._path[pathPosition], pathPosition, walksAlongPath, attacksFirst, isAffectedByMissionFailure); } - private async ValueTask SpawnMonsterAsync(short monsterNumber, DoppelgangerPathArea area, int pathPosition, bool walksAlongPath, bool attacksFirst, bool isAffectedByMissionFailure = false) + private async ValueTask SpawnMonsterAsync(MonsterDefinition? monsterDefinition, DoppelgangerPathArea area, int pathPosition, bool walksAlongPath, bool attacksFirst, bool isAffectedByMissionFailure = false) { - if (this.CreateSpawnArea(monsterNumber, area) is not { } spawnArea) + if (this.CreateSpawnArea(monsterDefinition, area) is not { } spawnArea) { return null; } - var monsterDefinition = spawnArea.MonsterDefinition!; - var intelligence = new DoppelgangerMonsterIntelligence(this._path, pathPosition, walksAlongPath, attacksFirst, this.OnMonsterReachedMagicCircleAsync, this.Logger); - var monster = new Monster(spawnArea, monsterDefinition, this.Map, this.DropGenerator, intelligence, this._gameContext.PlugInManager, this._gameContext.PathFinderPool, this); + 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 @@ -570,7 +586,7 @@ private async Task RunIceWalkerMissionAsync(CancellationToken cancellationToken) } catch (InvalidOperationException ex) { - this.Logger.LogWarning(ex, "{context}: Couldn't spawn monster {monsterNumber} at the path position {pathPosition}.", this, monsterNumber, pathPosition); + this.Logger.LogWarning(ex, "{context}: Couldn't spawn monster {monster} at the path position {pathPosition}.", this, monsterDefinition, pathPosition); monster.Dispose(); return null; } @@ -588,7 +604,7 @@ private async ValueTask SpawnInterimChestsAsync(Point position) var area = GetAreaAround(position, 2); for (var i = 0; i < this._definition.InterimChestCount; i++) { - if (await this.SpawnDestructibleAsync(this._definition.InterimRewardChestNumber, area).ConfigureAwait(false) is { } chest) + if (await this.SpawnDestructibleAsync(this._definition.InterimRewardChest, area).ConfigureAwait(false) is { } chest) { group.Chests.Add(chest); this._interimChests.TryAdd(chest, group); @@ -638,9 +654,9 @@ private async ValueTask DropChestItemsAsync(Player player, Destructible chest) } } - private async ValueTask SpawnDestructibleAsync(short number, DoppelgangerPathArea area) + private async ValueTask SpawnDestructibleAsync(MonsterDefinition? definition, DoppelgangerPathArea area) { - if (this.CreateSpawnArea(number, area) is not { } spawnArea) + if (this.CreateSpawnArea(definition, area) is not { } spawnArea) { return null; } @@ -654,7 +670,7 @@ private async ValueTask DropChestItemsAsync(Player player, Destructible chest) } catch (InvalidOperationException ex) { - this.Logger.LogWarning(ex, "{context}: Failed to spawn {number} around {area}.", this, number, area); + this.Logger.LogWarning(ex, "{context}: Failed to spawn {definition} around {area}.", this, definition, area); destructible.Dispose(); return null; } @@ -664,11 +680,11 @@ private async ValueTask DropChestItemsAsync(Player player, Destructible chest) return destructible; } - private MonsterSpawnArea? CreateSpawnArea(short monsterNumber, DoppelgangerPathArea area) + private MonsterSpawnArea? CreateSpawnArea(MonsterDefinition? monsterDefinition, DoppelgangerPathArea area) { - if (this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == monsterNumber) is not { } monsterDefinition) + if (monsterDefinition is null) { - this.Logger.LogWarning("{context}: Monster definition {monsterNumber} not found.", this, monsterNumber); + this.Logger.LogWarning("{context}: A monster of the event isn't configured.", this); return null; } diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs index 843316caa..200bdb4da 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs @@ -5,92 +5,44 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; /// -/// The default multipliers for the monsters, taken from the original season 6 server data. +/// The default multipliers for the monsters. /// public partial class DoppelgangerEventDefinition { + private const int MaximumPlayerCount = 5; + private const int ScalingLevelStep = 50; + private const int ScalingMaximumPlayerLevel = 800; + /// - /// Creates the multipliers of the monsters of the original season 6 event. Each entry applies - /// up to a player level (including the master level) and contains the multipliers for the - /// level, health, damage and defense for one to five players. - /// Higher player levels use the last entry. + /// 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() { - return - [ - CreateScaling(10, [1f, 1f, 1f, 1f, 1f], [1f, 2f, 3f, 3.8f, 4.8f], [1f, 2f, 3f, 3.7f, 4.7f], [1f, 2f, 3f, 3.7f, 4.5f]), - CreateScaling(20, [1.2f, 1.2f, 1.2f, 1.2f, 1.2f], [2f, 2.2f, 3.2f, 4f, 5f], [1.15f, 2.05f, 3.05f, 3.75f, 4.75f], [1.35f, 2.15f, 3.15f, 3.85f, 4.65f]), - CreateScaling(30, [1.6f, 1.6f, 1.6f, 1.6f, 1.6f], [2.7f, 2.4f, 3.4f, 4.2f, 5.2f], [1.2f, 2.1f, 3.1f, 3.8f, 4.8f], [1.5f, 2.3f, 3.3f, 4f, 4.8f]), - CreateScaling(40, [1.9f, 1.9f, 1.9f, 1.9f, 1.9f], [3.4f, 2.6f, 3.6f, 4.4f, 5.4f], [1.23f, 2.15f, 3.15f, 3.85f, 4.85f], [1.65f, 2.45f, 3.45f, 4.15f, 4.95f]), - CreateScaling(50, [2.2f, 2.2f, 2.2f, 2.2f, 2.2f], [4.1f, 2.8f, 3.8f, 4.6f, 5.6f], [1.43f, 2.18f, 3.18f, 3.88f, 4.88f], [1.8f, 2.6f, 3.6f, 4.3f, 5.1f]), - CreateScaling(60, [2.5f, 2.5f, 2.5f, 2.5f, 2.5f], [4.8f, 3.5f, 4.5f, 5.3f, 6.3f], [1.63f, 2.38f, 3.38f, 4.08f, 5.08f], [1.95f, 2.75f, 3.75f, 4.45f, 5.25f]), - CreateScaling(70, [2.7f, 2.7f, 2.7f, 2.7f, 2.7f], [5.5f, 4f, 5f, 5.8f, 6.8f], [1.83f, 2.58f, 3.58f, 4.28f, 5.28f], [2.1f, 2.9f, 3.9f, 4.6f, 5.4f]), - CreateScaling(80, [3f, 3f, 3f, 3f, 3f], [6.2f, 4.5f, 5.5f, 6.3f, 7.3f], [2.1f, 2.78f, 3.78f, 4.48f, 5.48f], [2.25f, 3.05f, 4.05f, 4.75f, 5.55f]), - CreateScaling(90, [3.3f, 3.3f, 3.3f, 3.3f, 3.3f], [6.9f, 5f, 6f, 6.8f, 7.8f], [2.31f, 2.98f, 3.98f, 4.68f, 5.68f], [2.4f, 3.2f, 4.2f, 4.9f, 5.8f]), - CreateScaling(100, [3.6f, 3.6f, 3.6f, 3.6f, 3.6f], [7.6f, 6f, 7f, 7.8f, 8.8f], [2.51f, 3.28f, 4.28f, 4.98f, 5.98f], [2.55f, 3.35f, 4.35f, 5.05f, 6.85f]), - CreateScaling(110, [3.65f, 3.65f, 3.65f, 3.65f, 3.65f], [8.3f, 6.5f, 7.5f, 8.3f, 9.3f], [2.71f, 3.48f, 4.48f, 5.18f, 6.18f], [2.7f, 3.5f, 4.5f, 5.2f, 6f]), - CreateScaling(120, [3.69f, 3.69f, 3.69f, 3.69f, 3.69f], [9f, 6.7f, 7.7f, 8.5f, 9.5f], [2.91f, 3.68f, 4.68f, 5.38f, 6.38f], [2.85f, 3.65f, 4.65f, 5.35f, 6.15f]), - CreateScaling(130, [3.73f, 3.73f, 3.73f, 3.73f, 3.73f], [9.7f, 6.9f, 7.9f, 8.7f, 9.7f], [3.11f, 3.88f, 4.88f, 5.58f, 6.58f], [3f, 3.8f, 4.8f, 5.5f, 6.3f]), - CreateScaling(140, [3.77f, 3.77f, 3.77f, 3.77f, 3.77f], [10.4f, 7.1f, 8.1f, 8.9f, 9.9f], [3.31f, 4.08f, 5.08f, 5.78f, 6.78f], [3.15f, 3.95f, 4.95f, 5.65f, 6.45f]), - CreateScaling(150, [3.81f, 3.81f, 3.81f, 3.81f, 3.81f], [11.1f, 7.3f, 8.3f, 9.1f, 10.1f], [3.51f, 4.28f, 5.28f, 5.98f, 6.98f], [3.3f, 4.1f, 5.1f, 5.8f, 6.6f]), - CreateScaling(160, [3.85f, 3.85f, 3.85f, 3.85f, 3.85f], [11.8f, 8f, 9f, 9.8f, 10.8f], [3.71f, 4.48f, 5.48f, 6.18f, 7.18f], [3.45f, 4.25f, 5.25f, 5.95f, 6.75f]), - CreateScaling(170, [3.89f, 3.89f, 3.89f, 3.89f, 3.89f], [12.5f, 9f, 10f, 10.8f, 11.8f], [3.81f, 4.68f, 5.68f, 6.38f, 7.38f], [4.6f, 4.4f, 5.4f, 6.1f, 6.9f]), - CreateScaling(180, [3.93f, 3.93f, 3.93f, 3.93f, 3.93f], [13.2f, 11f, 12f, 11.8f, 12.8f], [3.91f, 6f, 6.4f, 6.5f, 6.9f], [4.65f, 5.3f, 6.3f, 6.7f, 7f]), - CreateScaling(190, [3.97f, 3.97f, 3.97f, 3.97f, 3.97f], [13.5f, 12f, 13f, 12.8f, 13.8f], [4.08f, 6.4f, 6.8f, 6.7f, 7.18f], [4.7f, 5.8f, 6.4f, 6.7f, 7.3f]), - CreateScaling(200, [4.01f, 4.01f, 4.01f, 4.01f, 4.01f], [15f, 15f, 15f, 14.8f, 14.8f], [4.28f, 6.8f, 7f, 6.9f, 7.28f], [4.85f, 6.4f, 7f, 7.1f, 7.5f]), - CreateScaling(210, [4.05f, 4.05f, 4.05f, 4.05f, 4.05f], [16f, 17f, 18f, 18.8f, 18.8f], [4.48f, 7f, 7.2f, 7.1f, 7.38f], [5f, 7f, 7.5f, 7.5f, 7.8f]), - CreateScaling(220, [5f, 4.09f, 4.09f, 4.09f, 4.09f], [17.8f, 18f, 19f, 19.8f, 20.6f], [4.68f, 7.01f, 6.8f, 6.58f, 7.58f], [6.4f, 7.2f, 7.7f, 7.7f, 7.9f]), - CreateScaling(230, [5.05f, 4.13f, 4.13f, 4.13f, 4.13f], [18.8f, 19f, 20f, 20.8f, 21.6f], [4.88f, 7.02f, 7f, 6.78f, 7.78f], [6.6f, 7.4f, 7.9f, 7.9f, 8.1f]), - CreateScaling(240, [5.1f, 4.17f, 4.17f, 4.17f, 4.17f], [19.8f, 20f, 21f, 21.8f, 22.6f], [5.08f, 7.03f, 7.1f, 6.98f, 7.98f], [6.8f, 7.6f, 8.1f, 8.1f, 8.3f]), - CreateScaling(250, [5.15f, 4.21f, 4.21f, 4.21f, 4.21f], [20.8f, 21f, 22f, 22.8f, 23.6f], [5.28f, 7.04f, 7.28f, 7.18f, 8.18f], [7.2f, 7.8f, 8.3f, 8.3f, 8.5f]), - CreateScaling(260, [5.2f, 4.25f, 4.25f, 4.25f, 4.25f], [21.8f, 22f, 23f, 23.8f, 24.6f], [5.48f, 7.05f, 7.48f, 7.38f, 8.38f], [7.6f, 8f, 8.5f, 8.5f, 8.7f]), - CreateScaling(270, [5.25f, 4.29f, 4.29f, 4.29f, 4.29f], [22.8f, 23f, 24f, 24.8f, 25.6f], [5.68f, 7.06f, 7.68f, 7.58f, 8.58f], [8f, 8.2f, 8.7f, 8.7f, 8.9f]), - CreateScaling(280, [5.3f, 4.33f, 4.33f, 4.33f, 4.33f], [23.8f, 24f, 25f, 25.8f, 26.6f], [5.88f, 7.07f, 7.88f, 7.78f, 8.78f], [8.2f, 8.4f, 8.9f, 8.9f, 9.1f]), - CreateScaling(290, [5.35f, 4.37f, 4.37f, 4.37f, 4.37f], [25.8f, 25f, 26f, 26.8f, 27.6f], [6.18f, 7.08f, 8.08f, 7.98f, 8.98f], [8.6f, 8.6f, 9.1f, 9.1f, 9.3f]), - CreateScaling(300, [5.4f, 4.41f, 4.41f, 4.41f, 4.41f], [26.2f, 26f, 27f, 27.8f, 28.6f], [6.28f, 7.28f, 8.28f, 8.18f, 9.18f], [8.7f, 8.8f, 9.3f, 9.3f, 9.5f]), - CreateScaling(310, [5.45f, 4.45f, 4.45f, 4.45f, 4.45f], [26.8f, 27f, 28f, 28.8f, 29.6f], [6.48f, 7.48f, 8.48f, 8.38f, 9.38f], [8.8f, 9f, 9.5f, 9.5f, 9.7f]), - CreateScaling(320, [5.5f, 4.49f, 4.49f, 4.49f, 4.49f], [27.8f, 28f, 29f, 29.8f, 30.6f], [6.68f, 7.68f, 8.68f, 8.58f, 9.58f], [9f, 9.2f, 9.7f, 9.7f, 9.9f]), - CreateScaling(330, [5.55f, 4.53f, 4.53f, 4.53f, 4.53f], [29.8f, 29.5f, 30f, 30.8f, 31.6f], [6.98f, 7.88f, 8.88f, 8.78f, 9.78f], [9.4f, 9.4f, 9.9f, 9.9f, 10.1f]), - CreateScaling(340, [5.6f, 4.57f, 4.57f, 4.57f, 4.57f], [30.8f, 30.5f, 31f, 31.8f, 32.6f], [7.18f, 8.08f, 9.08f, 9.05f, 10.05f], [9.6f, 9.6f, 10.1f, 10.1f, 10.3f]), - CreateScaling(350, [5.65f, 4.61f, 4.61f, 4.61f, 4.61f], [31.8f, 31.5f, 32f, 32.8f, 33.6f], [7.38f, 8.35f, 9.35f, 9.18f, 10.18f], [9.8f, 9.8f, 10.3f, 10.3f, 10.5f]), - CreateScaling(360, [5.7f, 4.65f, 4.65f, 4.65f, 4.65f], [32.8f, 32.5f, 33f, 33.8f, 34.6f], [7.58f, 8.48f, 9.48f, 9.38f, 10.38f], [10f, 10f, 10.5f, 10.5f, 10.7f]), - CreateScaling(370, [5.75f, 4.69f, 4.69f, 4.69f, 4.69f], [33.8f, 33.5f, 34f, 34.8f, 35.6f], [7.78f, 8.68f, 9.68f, 9.58f, 10.58f], [10.2f, 10.2f, 10.7f, 10.7f, 10.9f]), - CreateScaling(380, [5.8f, 4.73f, 4.73f, 4.73f, 4.73f], [34.8f, 34.5f, 35f, 35.8f, 36.6f], [7.98f, 8.88f, 9.88f, 9.78f, 10.78f], [10.4f, 10.4f, 10.9f, 10.9f, 11.1f]), - CreateScaling(390, [5.85f, 4.77f, 4.77f, 4.77f, 4.77f], [35.8f, 35.5f, 36f, 36.8f, 37.6f], [8.18f, 9.08f, 10.08f, 9.98f, 10.98f], [10.6f, 10.6f, 11.1f, 11.1f, 11.3f]), - CreateScaling(400, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [38f, 37f, 38f, 38.8f, 39.8f], [8.48f, 9.28f, 10.28f, 10.18f, 11.18f], [11.6f, 11.5f, 11.4f, 11.4f, 11.5f]), - CreateScaling(410, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [39f, 41f, 42f, 42.8f, 43.8f], [8.58f, 9.68f, 10.78f, 10.38f, 11.38f], [12f, 12.5f, 12.8f, 12.5f, 12.3f]), - CreateScaling(420, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [42f, 50f, 52f, 61.8f, 66.8f], [8.68f, 9.88f, 10.78f, 10.58f, 11.58f], [12.3f, 13.5f, 15f, 15.7f, 17.5f]), - CreateScaling(430, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [47f, 52f, 57f, 66.8f, 71.8f], [8.88f, 9.98f, 10.88f, 10.78f, 11.78f], [12.6f, 14f, 15.5f, 16.2f, 18f]), - CreateScaling(440, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [53f, 58f, 63f, 72.8f, 77.8f], [9.08f, 10.08f, 11.08f, 10.98f, 11.98f], [12.9f, 14.3f, 16.3f, 17f, 18.8f]), - CreateScaling(450, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [59f, 64f, 69f, 78.8f, 83.8f], [9.38f, 10.28f, 11.28f, 11.18f, 12.18f], [13.1f, 15.1f, 17.1f, 17.8f, 19.6f]), - CreateScaling(460, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [69f, 70f, 75f, 84.8f, 89.8f], [9.98f, 11f, 12f, 12.38f, 13.38f], [14f, 15.9f, 17.9f, 18.6f, 20.4f]), - CreateScaling(470, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [76f, 76f, 81f, 90.8f, 95.8f], [10.28f, 12f, 13f, 13.1f, 13.7f], [14.7f, 16.7f, 18.7f, 19.4f, 21.2f]), - CreateScaling(480, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [82f, 82f, 87f, 96.8f, 100.8f], [10.58f, 12.5f, 13.5f, 13.5f, 14f], [15.2f, 17.5f, 19.5f, 20.2f, 22f]), - CreateScaling(490, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [88f, 88f, 93f, 101.8f, 106.8f], [10.88f, 13f, 13.8f, 13.7f, 14.3f], [16f, 18.3f, 20.3f, 21f, 22.8f]), - CreateScaling(500, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [93f, 94f, 99f, 107.8f, 112.8f], [11.18f, 13.4f, 14.1f, 14f, 14.6f], [16.7f, 19.1f, 21.1f, 21.8f, 23.6f]), - CreateScaling(510, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [95f, 100f, 105f, 113.8f, 118.8f], [11.38f, 13.7f, 14.4f, 14.3f, 14.9f], [17.4f, 19.9f, 21.9f, 22.6f, 24.4f]), - CreateScaling(520, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [101f, 106f, 111f, 119.8f, 124.8f], [11.48f, 14f, 14.7f, 14.6f, 15.2f], [17.7f, 20.7f, 22.7f, 23.4f, 25.2f]), - CreateScaling(530, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [107f, 112f, 117f, 125.8f, 130.8f], [11.78f, 14.3f, 15f, 14.9f, 15.5f], [18.5f, 21.5f, 23.5f, 24.2f, 26f]), - CreateScaling(540, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [113f, 118f, 123f, 131.8f, 136.8f], [12.08f, 14.6f, 15.3f, 15.2f, 15.8f], [19.3f, 22.3f, 24.3f, 25f, 26.8f]), - CreateScaling(550, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [119f, 124f, 129f, 137.8f, 142.8f], [12.38f, 14.9f, 15.6f, 15.5f, 16.1f], [20.1f, 23.1f, 25.1f, 25.8f, 27.6f]), - CreateScaling(560, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [125f, 130f, 135f, 143.8f, 148.8f], [12.68f, 15.2f, 15.9f, 15.8f, 16.4f], [20.9f, 23.9f, 25.9f, 26.6f, 28.4f]), - CreateScaling(570, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [131f, 136f, 141f, 149.8f, 154.8f], [12.98f, 15.5f, 16.2f, 16.1f, 16.7f], [21.7f, 24.7f, 26.7f, 27.4f, 29.2f]), - CreateScaling(580, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [137f, 142f, 147f, 155.8f, 160.8f], [13.28f, 15.8f, 16.5f, 16.4f, 17f], [22.5f, 25.5f, 27.5f, 28.2f, 30f]), - CreateScaling(590, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [143f, 148f, 153f, 161.8f, 166.8f], [13.58f, 16.1f, 16.8f, 16.7f, 17.3f], [23.3f, 26.3f, 28.3f, 29f, 30.8f]), - CreateScaling(600, [6.79f, 6.79f, 6.79f, 6.79f, 6.79f], [149f, 157f, 162f, 167.8f, 172.8f], [13.88f, 16.6f, 17.2f, 17f, 17.6f], [24.1f, 28.1f, 29.1f, 29.8f, 31.6f]), - ]; - } - - private static DoppelgangerMonsterScaling CreateScaling(int maximumPlayerLevel, float[] level, float[] health, float[] damage, float[] defense) - { - return new DoppelgangerMonsterScaling + var scalings = new List(); + for (var playerLevel = ScalingLevelStep; playerLevel <= ScalingMaximumPlayerLevel; playerLevel += ScalingLevelStep) { - MaximumPlayerLevel = maximumPlayerLevel, - LevelMultipliers = level, - HealthMultipliers = health, - DamageMultipliers = damage, - DefenseMultipliers = defense, - }; + 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/DoppelgangerEventDefinition.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs index 34554ffb7..72f3c41d3 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs @@ -4,11 +4,15 @@ 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 partial class DoppelgangerEventDefinition { /// @@ -28,10 +32,9 @@ public partial class DoppelgangerEventDefinition public TimeSpan HerdInterval { get; set; } = TimeSpan.FromSeconds(3); /// - /// Gets or sets the numbers of the monsters of which a herd consists. Each monster - /// of a herd is chosen randomly. + /// Gets or sets the monsters of which a herd consists. Each monster of a herd is chosen randomly. /// - public IList HerdMonsterNumbers { get; set; } = new List(); + public IList HerdMonsters { get; set; } = new List(); /// /// Gets or sets the base count of monsters of a herd, depending on the elapsed game time. @@ -46,9 +49,9 @@ public partial class DoppelgangerEventDefinition public double AttackFirstChance { get; set; } = 0.7; /// - /// Gets or sets the numbers of the monsters which always attack players in their range. + /// Gets or sets the monsters which always attack players in their range. /// - public IList AlwaysAttackingMonsterNumbers { get; set; } = new List(); + 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. @@ -56,9 +59,9 @@ public partial class DoppelgangerEventDefinition public IList AdditionalSpawns { get; set; } = new List(); /// - /// Gets or sets the number of the ice walker monster. + /// Gets or sets the ice walker monster. /// - public short IceWalkerNumber { get; set; } = 531; + public MonsterDefinition? IceWalker { get; set; } /// /// Gets or sets the elapsed game time after which the ice walkers appear. One ice walker @@ -94,14 +97,14 @@ public partial class DoppelgangerEventDefinition public IList MonsterScalings { get; set; } = new List(); /// - /// Gets or sets the numbers of the monsters which leave interim reward chests behind when they die. + /// Gets or sets the monsters which leave interim reward chests behind when they die. /// - public IList InterimChestMonsterNumbers { get; set; } = new List(); + public IList InterimChestMonsters { get; set; } = new List(); /// - /// Gets or sets the number of the interim reward chest. + /// Gets or sets the interim reward chest. /// - public short InterimRewardChestNumber { get; set; } = 541; + public MonsterDefinition? InterimRewardChest { get; set; } /// /// Gets or sets the number of interim reward chests which appear together. Only one of them can be opened. @@ -119,14 +122,14 @@ public partial class DoppelgangerEventDefinition public double LarvaChance { get; set; } = 0.6; /// - /// Gets or sets the number of the larva monster. One larva comes out of a chest for each player which started the event. + /// Gets or sets the larva monster. One larva comes out of a chest for each player which started the event. /// - public short LarvaNumber { get; set; } = 532; + public MonsterDefinition? Larva { get; set; } /// - /// Gets or sets the number of the final reward chest, which appears when the players successfully defended the magic circle. + /// Gets or sets the final reward chest, which appears when the players successfully defended the magic circle. /// - public short FinalRewardChestNumber { get; set; } = 542; + public MonsterDefinition? FinalRewardChest { get; set; } /// /// Gets or sets the paths of the monsters, one for each event map. @@ -134,11 +137,20 @@ public partial class DoppelgangerEventDefinition public IList Paths { get; set; } = new List(); /// - /// Creates the definition of the original season 6 event. + /// Creates the default definition of the event. /// + /// The game configuration, which contains the monsters of the event. /// The created definition. - public static DoppelgangerEventDefinition CreateDefault() + 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; @@ -147,9 +159,13 @@ public static DoppelgangerEventDefinition CreateDefault() return new DoppelgangerEventDefinition { // The dark lord clone (538) only appears as additional spawn. - HerdMonsterNumbers = [doppelganger, 534, 535, 536, 537, 539], - AlwaysAttackingMonsterNumbers = [doppelganger], - InterimChestMonsterNumbers = [terribleButcher, madButcher], + 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 }, @@ -158,9 +174,9 @@ public static DoppelgangerEventDefinition CreateDefault() ], AdditionalSpawns = [ - new DoppelgangerMonsterSpawn { SpawnTime = TimeSpan.FromMinutes(1), MonsterNumbers = [madButcher, doppelgangerDarkLord] }, - new DoppelgangerMonsterSpawn { SpawnTime = TimeSpan.FromMinutes(4), MonsterNumbers = [madButcher, doppelgangerDarkLord] }, - new DoppelgangerMonsterSpawn { SpawnTime = TimeSpan.FromMinutes(7), MonsterNumbers = [terribleButcher, doppelgangerDarkLord] }, + 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(), @@ -220,7 +236,7 @@ public int GetPathPosition(short mapNumber, Point point) } /// - /// Creates the paths of the original season 6 event. Each path consists of 23 areas, + /// 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. /// diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerFeaturePlugIn.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerFeaturePlugIn.cs new file mode 100644 index 000000000..60577a402 --- /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/DoppelgangerMonsterIntelligence.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs index a672adbb1..dfb1a68d9 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs @@ -239,7 +239,7 @@ private bool IsValidTarget(Monster monster, IAttackable target) /// private Point GetWalkTarget(DoppelgangerPathArea area) { - var center = area.Center; + var center = area.GetCenter(); if (this.CanWalkOn(center)) { return center; diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterSpawn.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterSpawn.cs index 944609130..b1cd4a313 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterSpawn.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterSpawn.cs @@ -4,6 +4,8 @@ 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. @@ -16,7 +18,7 @@ public class DoppelgangerMonsterSpawn public TimeSpan SpawnTime { get; set; } /// - /// Gets or sets the numbers of the monsters which spawn. + /// Gets or sets the monsters which spawn. /// - public IList MonsterNumbers { get; set; } = new List(); + public IList Monsters { get; set; } = new List(); } diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPathArea.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPathArea.cs index 1a4eac7c9..1f8ef3358 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPathArea.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPathArea.cs @@ -11,19 +11,57 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; /// along towards the magic circle. /// /// -/// The areas of a path overlap. As in the original server, the lower bounds are exclusive -/// and the upper bounds are inclusive. +/// The areas of a path overlap. The lower bounds are exclusive and the upper bounds are inclusive. /// -/// The lower x coordinate (exclusive). -/// The lower y coordinate (exclusive). -/// The upper x coordinate (inclusive). -/// The upper y coordinate (inclusive). -public record DoppelgangerPathArea(byte X1, byte Y1, byte X2, byte Y2) +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; } + /// /// Gets the center of the area, which is the target of the monsters walking to this area. /// - public Point Center => new((byte)(this.X1 + ((this.X2 - this.X1) / 2)), (byte)(this.Y1 + ((this.Y2 - this.Y1) / 2))); + /// The center of the area. + public Point GetCenter() => new((byte)(this.X1 + ((this.X2 - this.X1) / 2)), (byte)(this.Y1 + ((this.Y2 - this.Y1) / 2))); /// /// Determines whether the area contains the specified point. @@ -35,4 +73,7 @@ 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/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index ac21c839f..02d82f39d 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -609,6 +609,24 @@ 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.. /// diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 8ae3f4c1f..a5b2ce76a 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1296,4 +1296,10 @@ 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/Persistence/Initialization/DataInitializationBase.cs b/src/Persistence/Initialization/DataInitializationBase.cs index 0e16c8e63..6062d330c 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/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs index 7fa68a5a1..a824f56f0 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/DoppelgangerDataTest.cs @@ -6,6 +6,7 @@ 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; @@ -30,6 +31,25 @@ public async Task NewDatabaseContainsEventAsync() 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. diff --git a/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs index 922da8975..6b0fb3551 100644 --- a/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs +++ b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs @@ -4,6 +4,8 @@ namespace MUnique.OpenMU.Tests; +using Moq; +using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; using MUnique.OpenMU.Pathfinding; using NUnit.Framework; @@ -14,7 +16,7 @@ namespace MUnique.OpenMU.Tests; [TestFixture] public class DoppelgangerEventDefinitionTest { - private readonly DoppelgangerEventDefinition _definition = DoppelgangerEventDefinition.CreateDefault(); + private readonly DoppelgangerEventDefinition _definition = DoppelgangerEventDefinition.CreateDefault(CreateGameConfiguration()); /// /// Tests that the default definition contains a complete path for each of the four event maps. @@ -53,7 +55,7 @@ public void PathAreaBounds() 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(area.Center, Is.EqualTo(new Point(12, 22))); + Assert.That(area.GetCenter(), Is.EqualTo(new Point(12, 22))); } /// @@ -74,18 +76,17 @@ public void HerdBaseCount(int elapsedSeconds, int expectedCount) /// /// Tests that the multipliers of the monsters are chosen by the highest player level, rounded up to the next - /// range of ten levels, and that levels above the last range use the last one. + /// 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, 10)] - [TestCase(10, 10)] - [TestCase(11, 20)] + [TestCase(1, 50)] + [TestCase(50, 50)] + [TestCase(51, 100)] [TestCase(400, 400)] - [TestCase(401, 410)] - [TestCase(600, 600)] - [TestCase(601, 600)] - [TestCase(800, 600)] + [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)); @@ -97,7 +98,7 @@ public void MonsterScalingByPlayerLevel(int playerLevel, int expectedMaximumPlay [Test] public void DefaultMonsterScalingsAreComplete() { - Assert.That(this._definition.MonsterScalings, Has.Count.EqualTo(60)); + Assert.That(this._definition.MonsterScalings, Has.Count.EqualTo(16)); Assert.That( this._definition.MonsterScalings, Has.All.Matches(scaling => @@ -107,6 +108,28 @@ public void DefaultMonsterScalingsAreComplete() && 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. /// @@ -115,4 +138,11 @@ 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; + } } From 937e12ee5c378c9503c0abb693bc0961179d94bd Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:46:17 -0300 Subject: [PATCH 14/18] test(doppelganger): cover the packets, the path progress and the tickets - The packets of the event view, including the clamped path positions and the aligned reward experience, the enter result, the countdowns, the entrance window of Lugard and the handling of truncated enter requests. - The path progress of the monster intelligence: the position only increases with the next areas, a walking monster reaches the magic circle once, and a stationary monster next to it doesn't. - The ticket search: the mirror is preferred, a given slot is used and used up tickets are ignored. Co-Authored-By: Claude Opus 5.5 --- .../DoppelgangerMonsterIntelligence.cs | 39 ++-- .../MiniGames/EnterDoppelgangerAction.cs | 8 +- .../DoppelgangerMonsterIntelligenceTest.cs | 127 +++++++++++++ .../DoppelgangerRemoteViewTests.cs | 169 ++++++++++++++++++ .../EnterDoppelgangerActionTest.cs | 58 ++++++ 5 files changed, 382 insertions(+), 19 deletions(-) create mode 100644 tests/MUnique.OpenMU.Tests/DoppelgangerMonsterIntelligenceTest.cs create mode 100644 tests/MUnique.OpenMU.Tests/DoppelgangerRemoteViewTests.cs create mode 100644 tests/MUnique.OpenMU.Tests/EnterDoppelgangerActionTest.cs diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs index dfb1a68d9..33e51293b 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs @@ -126,24 +126,10 @@ public void Dispose() this._timer = null; } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")] - private async void SafeTick() - { - 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); - } - } - - private async ValueTask TickAsync() + /// + /// 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) @@ -183,6 +169,23 @@ private async ValueTask TickAsync() } } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")] + private async void SafeTick() + { + 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. diff --git a/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs b/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs index 9630ea6cf..d99aad0ec 100644 --- a/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs +++ b/src/GameLogic/PlayerActions/MiniGames/EnterDoppelgangerAction.cs @@ -56,7 +56,13 @@ public async ValueTask TryEnterAsync(Player player, byte ticketInventoryIndex) } } - private static Item? FindTicket(Player player, byte ticketInventoryIndex) + /// + /// 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) { diff --git a/tests/MUnique.OpenMU.Tests/DoppelgangerMonsterIntelligenceTest.cs b/tests/MUnique.OpenMU.Tests/DoppelgangerMonsterIntelligenceTest.cs new file mode 100644 index 000000000..76aba95fa --- /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 000000000..4a2fffcd1 --- /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 000000000..94dbb6ba2 --- /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; + } +} From e905d298c173056e5428a87399ca5d592659a95d Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:47:26 -0300 Subject: [PATCH 15/18] docs(doppelganger): describe values as configurable defaults The comments describe the values as defaults which can be adjusted, and the description of the reward experience of the result packet starts with what it is, so the generated documentation reads well. Co-Authored-By: Claude Opus 5.5 --- docs/Packets/C1-BF-13-DoppelgangerResult_by-server.md | 2 +- src/Network/Packets/ServerToClient/ConnectionExtensions.cs | 2 +- src/Network/Packets/ServerToClient/ServerToClientPackets.cs | 2 +- src/Network/Packets/ServerToClient/ServerToClientPackets.xml | 2 +- .../Packets/ServerToClient/ServerToClientPacketsRef.cs | 2 +- .../VersionSeasonSix/Events/DoppelgangerMonsters.cs | 4 ++-- src/Persistence/Initialization/VersionSeasonSix/Gates.cs | 2 +- .../DoppelgangerDataTest.cs | 2 +- tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs | 3 +-- 9 files changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/Packets/C1-BF-13-DoppelgangerResult_by-server.md b/docs/Packets/C1-BF-13-DoppelgangerResult_by-server.md index f83a1a2f3..4a0f77ac0 100644 --- a/docs/Packets/C1-BF-13-DoppelgangerResult_by-server.md +++ b/docs/Packets/C1-BF-13-DoppelgangerResult_by-server.md @@ -17,7 +17,7 @@ The client stops the timer and the event music, and shows a message box with the | 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; It is not shown by the client. The field is aligned to 4 bytes, because the original client structure is not packed. | +| 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 diff --git a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs index 85691cbcb..43deceab9 100644 --- a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs +++ b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs @@ -5621,7 +5621,7 @@ int WritePacket() /// /// The connection. /// The result. - /// It is not shown by the client. The field is aligned to 4 bytes, because the original client structure is not packed. + /// 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. diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs index c7d5877ad..9436b1ad1 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs @@ -27475,7 +27475,7 @@ public DoppelgangerResult.ResultType Result } /// - /// Gets or sets it is not shown by the client. The field is aligned to 4 bytes, because the original client structure is not packed. + /// 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 { diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml index 3877859fa..4ccbd901c 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -9937,7 +9937,7 @@ 8 IntegerLittleEndian RewardExperience - It is not shown by the client. The field is aligned to 4 bytes, because the original client structure is not packed. + 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. diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs index 6cbd6823f..63519dcdd 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs @@ -26047,7 +26047,7 @@ public DoppelgangerResult.ResultType Result } /// - /// Gets or sets it is not shown by the client. The field is aligned to 4 bytes, because the original client structure is not packed. + /// 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 { diff --git a/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs index 943abef27..d952bb7cb 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Events/DoppelgangerMonsters.cs @@ -13,7 +13,7 @@ namespace MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Events; /// The initializer for the monsters of the doppelganger event. /// /// -/// The values are the base values of the original season 6 server data. +/// The values are default values, which can be adjusted in the admin panel. /// internal class DoppelgangerMonsters : InitializerBase { @@ -63,7 +63,7 @@ public override void Initialize() /// /// 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, similar to the original item bags. + /// or Crest of Monarch. /// internal void ConfigureRewardChests() { diff --git a/src/Persistence/Initialization/VersionSeasonSix/Gates.cs b/src/Persistence/Initialization/VersionSeasonSix/Gates.cs index 3982367a1..79bf92d64 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Gates.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Gates.cs @@ -423,7 +423,7 @@ private IDictionary CreateTargetGates(IDictionary map.Number == 65).ExitGates.Single(gate => gate.IsSpawnGate); (entrance.X1, entrance.Y1, entrance.X2, entrance.Y2) = ((byte)193, (byte)26, (byte)200, (byte)32); diff --git a/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs index 6b0fb3551..9ccb31601 100644 --- a/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs +++ b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs @@ -44,8 +44,7 @@ public void PathPosition(byte x, byte y, int expectedPosition) } /// - /// Tests that the lower bounds of a path area are exclusive and the upper bounds are inclusive, - /// as in the original server. + /// Tests that the lower bounds of a path area are exclusive and the upper bounds are inclusive. /// [Test] public void PathAreaBounds() From edfd4c67fe4b363fccff7c7105fe9bdcbd3512fb Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:01:21 -0300 Subject: [PATCH 16/18] style(doppelganger): order the members of the OpenNpcWindowPlugIn The method which opens the entrance window of the doppelganger event is placed after the static members (SA1204). Co-Authored-By: Claude Opus 5.5 --- .../RemoteView/NPC/OpenNpcWindowPlugIn.cs | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/GameServer/RemoteView/NPC/OpenNpcWindowPlugIn.cs b/src/GameServer/RemoteView/NPC/OpenNpcWindowPlugIn.cs index 4d42964ef..30b99258e 100644 --- a/src/GameServer/RemoteView/NPC/OpenNpcWindowPlugIn.cs +++ b/src/GameServer/RemoteView/NPC/OpenNpcWindowPlugIn.cs @@ -47,34 +47,6 @@ public async ValueTask OpenNpcWindowAsync(NpcWindow window) } } - /// - /// 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); - } - private static NpcWindowResponse.NpcWindow Convert(NpcWindow window) { return window switch @@ -109,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); + } +} From af8546d3b69e813bcd16c332aec56b5940bea2ae Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:49:30 -0300 Subject: [PATCH 17/18] style(doppelganger): address static code analysis findings - The timer callback of the monster intelligence awaits a Task instead of an async void method (S3168). - The chain of conditions when opening a chest ends with an else (S126). - The default monster multipliers are part of the event definition itself, instead of a partial class with only static members (S1118). - The center of a path area is calculated by the intelligence, which is its only user, instead of a method which looks like a property (S4049). - The maximum path position is a static property of the view interface instead of a constant (S2339). - The magic circle callback is invoked explicitly (S4487). Co-Authored-By: Claude Opus 5.5 --- .../Doppelganger/DoppelgangerContext.cs | 4 ++ ...elgangerEventDefinition.MonsterScalings.cs | 48 ------------------- .../DoppelgangerEventDefinition.cs | 39 ++++++++++++++- .../DoppelgangerMonsterIntelligence.cs | 19 ++++++-- .../Doppelganger/DoppelgangerPathArea.cs | 6 --- .../IDoppelgangerEventViewPlugIn.cs | 4 +- .../DoppelgangerEventDefinitionTest.cs | 2 +- 7 files changed, 59 insertions(+), 63 deletions(-) delete mode 100644 src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs index a219ccba0..e14ffd1a5 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerContext.cs @@ -191,6 +191,10 @@ public async ValueTask OpenRewardChestAsync(Player player, Destructible chest) { 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"); diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs deleted file mode 100644 index 200bdb4da..000000000 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.MonsterScalings.cs +++ /dev/null @@ -1,48 +0,0 @@ -// -// Licensed under the MIT License. See LICENSE file in the project root for full license information. -// - -namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; - -/// -/// The default multipliers for the monsters. -/// -public partial class DoppelgangerEventDefinition -{ - private const int MaximumPlayerCount = 5; - private const int ScalingLevelStep = 50; - private const int ScalingMaximumPlayerLevel = 800; - - /// - /// 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/DoppelgangerEventDefinition.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs index 72f3c41d3..9c9760d21 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerEventDefinition.cs @@ -13,8 +13,12 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; /// /// It's configured at the , so it can be adapted in the admin panel. /// -public partial class DoppelgangerEventDefinition +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. /// @@ -366,4 +370,37 @@ private static IList CreateDefaultPaths() }, ]; } + + /// + /// 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/DoppelgangerMonsterIntelligence.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs index 33e51293b..db59b044d 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs @@ -101,7 +101,7 @@ public void RegisterHit(IAttacker attacker) /// public void Start() { - this._timer ??= new Timer(_ => this.SafeTick(), null, this.Monster.Definition.AttackDelay, this.Monster.Definition.AttackDelay); + this._timer ??= new Timer(_ => _ = this.SafeTickAsync(), null, this.Monster.Definition.AttackDelay, this.Monster.Definition.AttackDelay); } /// @@ -126,6 +126,16 @@ public void 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. /// @@ -144,7 +154,7 @@ internal async ValueTask TickAsync() if (Interlocked.Exchange(ref this._hasReachedMagicCircle, 1) == 0) { this.Dispose(); - await this._onMagicCircleReached(monster).ConfigureAwait(false); + await this._onMagicCircleReached.Invoke(monster).ConfigureAwait(false); } return; @@ -169,8 +179,7 @@ internal async ValueTask TickAsync() } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")] - private async void SafeTick() + private async Task SafeTickAsync() { try { @@ -242,7 +251,7 @@ private bool IsValidTarget(Monster monster, IAttackable target) /// private Point GetWalkTarget(DoppelgangerPathArea area) { - var center = area.GetCenter(); + var center = GetCenter(area); if (this.CanWalkOn(center)) { return center; diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPathArea.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPathArea.cs index 1f8ef3358..c33775fe2 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPathArea.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerPathArea.cs @@ -57,12 +57,6 @@ public DoppelgangerPathArea(byte x1, byte y1, byte x2, byte y2) /// public byte Y2 { get; set; } - /// - /// Gets the center of the area, which is the target of the monsters walking to this area. - /// - /// The center of the area. - public Point GetCenter() => new((byte)(this.X1 + ((this.X2 - this.X1) / 2)), (byte)(this.Y1 + ((this.Y2 - this.Y1) / 2))); - /// /// Determines whether the area contains the specified point. /// diff --git a/src/GameLogic/MiniGames/Doppelganger/IDoppelgangerEventViewPlugIn.cs b/src/GameLogic/MiniGames/Doppelganger/IDoppelgangerEventViewPlugIn.cs index a5c772b8c..bac1eefbb 100644 --- a/src/GameLogic/MiniGames/Doppelganger/IDoppelgangerEventViewPlugIn.cs +++ b/src/GameLogic/MiniGames/Doppelganger/IDoppelgangerEventViewPlugIn.cs @@ -16,9 +16,9 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Doppelganger; public interface IDoppelgangerEventViewPlugIn : IViewPlugIn { /// - /// The maximum position index on the path, which is the position of the magic circle. + /// Gets the maximum position index on the path, which is the position of the magic circle. /// - const int MaximumPathPosition = 22; + static int MaximumPathPosition => 22; /// /// Shows the state of the event. diff --git a/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs index 9ccb31601..3eea9e66c 100644 --- a/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs +++ b/tests/MUnique.OpenMU.Tests/DoppelgangerEventDefinitionTest.cs @@ -54,7 +54,7 @@ public void PathAreaBounds() 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(area.GetCenter(), Is.EqualTo(new Point(12, 22))); + Assert.That(DoppelgangerMonsterIntelligence.GetCenter(area), Is.EqualTo(new Point(12, 22))); } /// From f8d2e9d4cc43cf329e3af04051b20e04cd293312 Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:53:45 -0300 Subject: [PATCH 18/18] style(doppelganger): discard the task of the timer callback The parameter of the timer callback was named like a discard, so the task was assigned to the parameter instead of being discarded (S1226, S1854). Co-Authored-By: Claude Opus 5.5 --- .../MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs index db59b044d..2eef7cc5d 100644 --- a/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs +++ b/src/GameLogic/MiniGames/Doppelganger/DoppelgangerMonsterIntelligence.cs @@ -101,7 +101,7 @@ public void RegisterHit(IAttacker attacker) /// public void Start() { - this._timer ??= new Timer(_ => _ = this.SafeTickAsync(), null, this.Monster.Definition.AttackDelay, this.Monster.Definition.AttackDelay); + this._timer ??= new Timer(state => _ = this.SafeTickAsync(), null, this.Monster.Definition.AttackDelay, this.Monster.Definition.AttackDelay); } ///