From 2c6a2a92eccda90ba2472ee1e7e78941dd05719b Mon Sep 17 00:00:00 2001 From: bulgarashi Date: Sun, 23 Aug 2026 13:58:08 +0200 Subject: [PATCH 01/11] Add Illusion Temple mini-game: core loop, skill points and special skills Implements the full Illusion Temple event as a team-based PvP mini game: - Stone Statue (NPC 380) holds a sacred relic; a player talks to it to become the carrier, delivers it to his team's storage (383/384) to score, and drops it on death or when leaving. - Two teams (Allied/Illusion Forces), assigned and spawned on game start, with a live per-player state update (own team positions, relic carrier, remaining time) and an end-of-game result screen. - A team needs at least 2 points, and more than the opposing team, to be declared the winner - matching the original event (a 1:0 finish is a draw, like in the reference server). - Experience is granted automatically to the winning team at game end; other reward types (item drops) are only granted once the player explicitly claims them (0xBF05), closing the result dialog - mirroring the original "click Close to be compensated" flow. - Skill points (start at 10, cap 90): +1 for killing an enemy player, +2 for killing a roaming arena monster. They fuel four special skills (210 Order of Protection, 211 Restraint, 212 Tracking, 213 Weaken), each costing 10 points, requested via a dedicated 0xBF02 packet. - MiniGameDefinition.MinimumPlayerCount is now admin-configurable (EF migration included) instead of hardcoded per event type. - Fixed a base MiniGameContext bug where players stuck on an event map with too few participants were never moved back to safezone. New server<->client packets: IllusionTempleEventState, HolyItemRelics, SkillUsageResult, SkillPointUpdate, SkillEnded, RewardRequest handling, and corrected byte layouts for IllusionTempleState/Result (missing RelicCarrierId field, wrong array offset, and a 3-byte padding gap in PlayerResult that the original client's C struct alignment expects). Co-Authored-By: Claude Sonnet 5 --- .../C1-BF-01-IllusionTempleState_by-server.md | 24 +- ...C1-BF-04-IllusionTempleResult_by-server.md | 8 +- ...F-09-IllusionTempleEventState_by-server.md | 31 + docs/Packets/ServerToClient.md | 1 + .../Configuration/MiniGameDefinition.cs | 6 + src/GameLogic/GameContext.cs | 3 + .../IIllusionTempleEventStateViewPlugIn.cs | 50 + ...IIllusionTempleHolyItemRelicsViewPlugIn.cs | 21 + .../IIllusionTempleScoreTableViewPlugIn.cs | 21 + .../IIllusionTempleSkillEndedViewPlugin.cs | 21 + ...llusionTempleSkillPointUpdateViewPlugin.cs | 20 + ...llusionTempleSkillUsageResultViewPlugin.cs | 23 + .../IIllusionTempleStateViewPlugin.cs | 38 + .../IShowIllusionTempleUserCountViewPlugIn.cs | 19 + .../MiniGames/IllusionTempleContext.cs | 1011 ++++ .../MiniGames/IllusionTempleScore.cs | 86 + src/GameLogic/MiniGames/IllusionTempleTeam.cs | 21 + src/GameLogic/MiniGames/MiniGameContext.cs | 30 +- .../Craftings/IllusionTempleTicketCrafting.cs | 2 +- .../MiniGames/EnterMiniGameAction.cs | 29 + src/GameLogic/PlayerActions/TalkNpcAction.cs | 63 + src/GameLogic/PlayerMapTransitions.cs | 10 +- .../PlugIns/MiniGameSpawnGatePlugIn.cs | 36 + .../Properties/PlayerMessage.Designer.cs | 47 +- src/GameLogic/Properties/PlayerMessage.resx | 15 + .../IllusionTempleEnterHandlerPlugin.cs | 68 + ...llusionTempleRewardRequestHandlerPlugIn.cs | 44 + ...IllusionTempleSkillRequestHandlerPlugIn.cs | 45 + .../RemoteView/MiniGames/Extensions.cs | 15 + .../IllusionTempleEventStateViewPlugIn.cs | 49 + .../IllusionTempleHolyItemRelicsViewPlugIn.cs | 56 + .../IllusionTempleScoreTableViewPlugIn.cs | 70 + .../IllusionTempleSkillEndedViewPlugIn.cs | 56 + ...llusionTempleSkillPointUpdateViewPlugIn.cs | 55 + ...llusionTempleSkillUsageResultViewPlugIn.cs | 58 + .../IllusionTempleStateViewPlugIn.cs | 97 + .../IllusionTempleUserCountViewPlugin.cs | 52 + .../ShowMiniGameEnterResultViewPlugIn.cs | 4 + .../ServerToClient/ConnectionExtensions.cs | 30 + .../ServerToClient/ServerToClientPackets.cs | 213 +- .../ServerToClient/ServerToClientPackets.xml | 96 +- .../ServerToClientPacketsRef.cs | 187 +- ..._AddMiniGameMinimumPlayerCount.Designer.cs | 5296 +++++++++++++++++ ...819113052_AddMiniGameMinimumPlayerCount.cs | 31 + .../EntityDataContextModelSnapshot.cs | 3 + .../Skills/MagicEffectNumber.cs | 10 + .../Events/IllusionTempleInitializer.cs | 160 + .../GameConfigurationInitializer.cs | 1 + .../VersionSeasonSix/NpcInitialization.cs | 59 + .../ServerToClientPacketTests.cs | 56 +- 50 files changed, 8282 insertions(+), 165 deletions(-) create mode 100644 docs/Packets/C1-BF-09-IllusionTempleEventState_by-server.md create mode 100644 src/GameLogic/MiniGames/IIllusionTempleEventStateViewPlugIn.cs create mode 100644 src/GameLogic/MiniGames/IIllusionTempleHolyItemRelicsViewPlugIn.cs create mode 100644 src/GameLogic/MiniGames/IIllusionTempleScoreTableViewPlugIn.cs create mode 100644 src/GameLogic/MiniGames/IIllusionTempleSkillEndedViewPlugin.cs create mode 100644 src/GameLogic/MiniGames/IIllusionTempleSkillPointUpdateViewPlugin.cs create mode 100644 src/GameLogic/MiniGames/IIllusionTempleSkillUsageResultViewPlugin.cs create mode 100644 src/GameLogic/MiniGames/IIllusionTempleStateViewPlugin.cs create mode 100644 src/GameLogic/MiniGames/IShowIllusionTempleUserCountViewPlugIn.cs create mode 100644 src/GameLogic/MiniGames/IllusionTempleContext.cs create mode 100644 src/GameLogic/MiniGames/IllusionTempleScore.cs create mode 100644 src/GameLogic/MiniGames/IllusionTempleTeam.cs create mode 100644 src/GameLogic/PlugIns/MiniGameSpawnGatePlugIn.cs create mode 100644 src/GameServer/MessageHandler/MiniGames/IllusionTempleEnterHandlerPlugin.cs create mode 100644 src/GameServer/MessageHandler/MiniGames/IllusionTempleRewardRequestHandlerPlugIn.cs create mode 100644 src/GameServer/MessageHandler/MiniGames/IllusionTempleSkillRequestHandlerPlugIn.cs create mode 100644 src/GameServer/RemoteView/MiniGames/IllusionTempleEventStateViewPlugIn.cs create mode 100644 src/GameServer/RemoteView/MiniGames/IllusionTempleHolyItemRelicsViewPlugIn.cs create mode 100644 src/GameServer/RemoteView/MiniGames/IllusionTempleScoreTableViewPlugIn.cs create mode 100644 src/GameServer/RemoteView/MiniGames/IllusionTempleSkillEndedViewPlugIn.cs create mode 100644 src/GameServer/RemoteView/MiniGames/IllusionTempleSkillPointUpdateViewPlugIn.cs create mode 100644 src/GameServer/RemoteView/MiniGames/IllusionTempleSkillUsageResultViewPlugIn.cs create mode 100644 src/GameServer/RemoteView/MiniGames/IllusionTempleStateViewPlugIn.cs create mode 100644 src/GameServer/RemoteView/MiniGames/IllusionTempleUserCountViewPlugin.cs create mode 100644 src/Persistence/EntityFramework/Migrations/20260819113052_AddMiniGameMinimumPlayerCount.Designer.cs create mode 100644 src/Persistence/EntityFramework/Migrations/20260819113052_AddMiniGameMinimumPlayerCount.cs create mode 100644 src/Persistence/Initialization/VersionSeasonSix/Events/IllusionTempleInitializer.cs diff --git a/docs/Packets/C1-BF-01-IllusionTempleState_by-server.md b/docs/Packets/C1-BF-01-IllusionTempleState_by-server.md index bb0ee0db3b..a962068393 100644 --- a/docs/Packets/C1-BF-01-IllusionTempleState_by-server.md +++ b/docs/Packets/C1-BF-01-IllusionTempleState_by-server.md @@ -6,7 +6,7 @@ The player is in the illusion temple event and the server sends a cyclic update. ## Causes the following actions on the client side -The client shows the state in the user interface. +The client shows the score board, the remaining time, and the carrier of the holy relic and the own team mates on its mini map. ## Structure @@ -17,24 +17,24 @@ The client shows the state in the user interface. | 2 | 1 | Byte | 0xBF | Packet header - packet type identifier | | 3 | 1 | Byte | 0x01 | Packet header - sub packet type identifier | | 4 | 2 | ShortLittleEndian | | RemainingSeconds | -| 4 | 2 | ShortLittleEndian | | PlayerIndex | -| 6 | 1 | Byte | | PositionX | -| 7 | 1 | Byte | | PositionY | -| 8 | 1 | Byte | | Team1Points | -| 9 | 1 | Byte | | Team2Points | -| 10 | 1 | Byte | | MyTeam | -| 11 | 1 | Byte | | PartyCount | -| 12 | IllusionTemplePartyEntry.Length * | Array of IllusionTemplePartyEntry | | PartyMembers | +| 6 | 2 | ShortLittleEndian | | RelicCarrierId | +| 8 | 1 | Byte | | PositionX | +| 9 | 1 | Byte | | PositionY | +| 10 | 1 | Byte | | AlliedForcesPoints | +| 11 | 1 | Byte | | IllusionForcesPoints | +| 12 | 1 | Byte | | MyTeam | +| 13 | 1 | Byte | | PartyCount | +| 14 | IllusionTempleTeamMate.Length * | Array of IllusionTempleTeamMate | | TeamMates | -### IllusionTemplePartyEntry Structure +### IllusionTempleTeamMate Structure -Contains the info about a party member in illusion temple. +Contains the info about a team mate in the illusion temple, so that the client can show him on its mini map. Only PartyCount entries are sent - there are no unused/zeroed slots. Length: 5 Bytes | Index | Length | Data Type | Value | Description | |-------|--------|-----------|-------|-------------| | 0 | 2 | ShortLittleEndian | | PlayerId | -| 2 | 2 | ShortLittleEndian | | MapNumber | +| 2 | 1 | Byte | | MapNumber | | 3 | 1 | Byte | | PositionX | | 4 | 1 | Byte | | PositionY | \ No newline at end of file diff --git a/docs/Packets/C1-BF-04-IllusionTempleResult_by-server.md b/docs/Packets/C1-BF-04-IllusionTempleResult_by-server.md index e3a4e3b9e6..b2250520b7 100644 --- a/docs/Packets/C1-BF-04-IllusionTempleResult_by-server.md +++ b/docs/Packets/C1-BF-04-IllusionTempleResult_by-server.md @@ -19,18 +19,18 @@ The client shows the results. | 4 | 1 | Byte | | Team1Points | | 5 | 1 | Byte | | Team2Points | | 6 | 1 | Byte | | PlayerCount | -| 10 | PlayerResult.Length * PlayerCount | Array of PlayerResult | | Players | +| 7 | PlayerResult.Length * PlayerCount | Array of PlayerResult | | Players | ### PlayerResult Structure Contains the result of a player in the event. -Length: 17 Bytes +Length: 20 Bytes | Index | Length | Data Type | Value | Description | |-------|--------|-----------|-------|-------------| -| 0 | | String | | Name | +| 0 | 10 | String | | Name | | 10 | 1 | Byte | | MapNumber | | 11 | 1 | Byte | | Team | | 12 | 1 | Byte | | Class | -| 13 | 4 | IntegerLittleEndian | | AddedExperience | \ No newline at end of file +| 16 | 4 | IntegerLittleEndian | | AddedExperience | \ No newline at end of file diff --git a/docs/Packets/C1-BF-09-IllusionTempleEventState_by-server.md b/docs/Packets/C1-BF-09-IllusionTempleEventState_by-server.md new file mode 100644 index 0000000000..55c6d323ab --- /dev/null +++ b/docs/Packets/C1-BF-09-IllusionTempleEventState_by-server.md @@ -0,0 +1,31 @@ +# C1 BF 09 - IllusionTempleEventState (by server) + +## Is sent when + +The state of an illusion temple event changed, e.g. when the battle starts. + +## Causes the following actions on the client side + +The client shows or hides the user interface of the event - the score board, the timer and the mini map - and applies the barriers of the arena, which are hardcoded at client side. + +## 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 | 0x09 | Packet header - sub packet type identifier | +| 4 | 1 | Byte | | TempleNumber | +| 5 | 1 | EventState | | State | + +### EventState Enum + +Defines the state of an illusion temple event. + +| Value | Name | Description | +|-------|------|-------------| +| 0 | WaitingRoom | The player entered the event and waits for it to start. It's only sent to the entering player, not to all participants. | +| 1 | Preparation | The preparation started: the players have been moved into the arena and assigned to their teams. The client opens the event interface with the score board, the timer and the mini map. | +| 2 | BattleStarted | The battle started: the statues are up and the barriers of the arena are removed, so that the players can reach the cursed statue. | +| 3 | Ended | The battle ended - the client closes the event interface. | \ No newline at end of file diff --git a/docs/Packets/ServerToClient.md b/docs/Packets/ServerToClient.md index c01b4748c7..811c698e27 100644 --- a/docs/Packets/ServerToClient.md +++ b/docs/Packets/ServerToClient.md @@ -197,6 +197,7 @@ * [C1 BF 07 - IllusionTempleSkillEnded (by server)](C1-BF-07-IllusionTempleSkillEnded_by-server.md) * [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 09 - IllusionTempleEventState (by server)](C1-BF-09-IllusionTempleEventState_by-server.md) * [C1 BF 0A - ChainLightningHitInfo (by server)](C1-BF-0A-ChainLightningHitInfo_by-server.md) * [C1 BF 51 - MuHelperStatusUpdate (by server)](C1-BF-51-MuHelperStatusUpdate_by-server.md) * [C2 C0 - MessengerInitialization (by server)](C2-C0-MessengerInitialization_by-server.md) diff --git a/src/DataModel/Configuration/MiniGameDefinition.cs b/src/DataModel/Configuration/MiniGameDefinition.cs index 5dde513b79..b168aa23de 100644 --- a/src/DataModel/Configuration/MiniGameDefinition.cs +++ b/src/DataModel/Configuration/MiniGameDefinition.cs @@ -61,6 +61,12 @@ public partial class MiniGameDefinition /// public int MaximumPlayerCount { get; set; } + /// + /// Gets or sets the minimum player count which is required for the game to start, and below which + /// a running game is aborted early. A value of 0 means the game type's built-in default applies. + /// + public int MinimumPlayerCount { get; set; } + /// /// Gets or sets a value indicating whether to save the score as . /// diff --git a/src/GameLogic/GameContext.cs b/src/GameLogic/GameContext.cs index 6bb434b600..a9a01e3e6e 100644 --- a/src/GameLogic/GameContext.cs +++ b/src/GameLogic/GameContext.cs @@ -283,6 +283,9 @@ public async ValueTask GetMiniGameAsync(MiniGameDefinition mini case MiniGameType.BloodCastle: miniGameContext = new BloodCastleContext(miniGameKey, miniGameDefinition, this, this._mapInitializer); break; + case MiniGameType.IllusionTemple: + miniGameContext = new IllusionTempleContext(miniGameKey, miniGameDefinition, this, this._mapInitializer); + break; default: miniGameContext = new MiniGameContext(miniGameKey, miniGameDefinition, this, this._mapInitializer); break; diff --git a/src/GameLogic/MiniGames/IIllusionTempleEventStateViewPlugIn.cs b/src/GameLogic/MiniGames/IIllusionTempleEventStateViewPlugIn.cs new file mode 100644 index 0000000000..ee8f08e372 --- /dev/null +++ b/src/GameLogic/MiniGames/IIllusionTempleEventStateViewPlugIn.cs @@ -0,0 +1,50 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.MiniGames; + +using MUnique.OpenMU.GameLogic.Views; + +/// +/// The state of an illusion temple event, as far as the game client is concerned. +/// +public enum IllusionTempleEventStatus +{ + /// + /// The player entered the event and waits for it to start. In contrast to the other states, this + /// one is only told to the entering player, not to all participants. + /// + WaitingRoom = 0, + + /// + /// The preparation started: the players have been moved into the arena and assigned to their teams. + /// The client opens the event interface with the score board, the timer and the mini map. + /// + Preparation = 1, + + /// + /// The battle started: the statues are up and the barriers of the arena are removed, so that the + /// players can reach the cursed statue. The barrier areas are hardcoded at client side, so this is + /// the only way for the server to open them. + /// + BattleStarted = 2, + + /// + /// The battle ended - the client closes the event interface. + /// + Ended = 3, +} + +/// +/// Interface of a view whose implementation informs about the state of an illusion temple event. +/// +public interface IIllusionTempleEventStateViewPlugIn : IViewPlugIn +{ + /// + /// Changes the state of the illusion temple event at the client. + /// + /// The number of the temple, from 1 to 6. + /// The new state of the event. + ValueTask ChangeEventStateAsync(byte templeNumber, IllusionTempleEventStatus state); +} diff --git a/src/GameLogic/MiniGames/IIllusionTempleHolyItemRelicsViewPlugIn.cs b/src/GameLogic/MiniGames/IIllusionTempleHolyItemRelicsViewPlugIn.cs new file mode 100644 index 0000000000..6c1ea14522 --- /dev/null +++ b/src/GameLogic/MiniGames/IIllusionTempleHolyItemRelicsViewPlugIn.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; + +using MUnique.OpenMU.GameLogic.Views; + +/// +/// Interface of a view whose implementation announces the player who just picked up the holy relic of +/// an illusion temple event. +/// +public interface IIllusionTempleHolyItemRelicsViewPlugIn : IViewPlugIn +{ + /// + /// Announces the player who just picked up the holy relic. + /// + /// The id of the player who picked up the relic. + /// The name of the player who picked up the relic. + ValueTask ShowHolyItemRelicsAsync(ushort playerId, string playerName); +} diff --git a/src/GameLogic/MiniGames/IIllusionTempleScoreTableViewPlugIn.cs b/src/GameLogic/MiniGames/IIllusionTempleScoreTableViewPlugIn.cs new file mode 100644 index 0000000000..f767e5c630 --- /dev/null +++ b/src/GameLogic/MiniGames/IIllusionTempleScoreTableViewPlugIn.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; + +using MUnique.OpenMU.GameLogic.Views; + +/// +/// Interface of a view whose implementation informs about the result of an illusion temple event. +/// +public interface IIllusionTempleScoreTableViewPlugIn : IViewPlugIn +{ + /// + /// Shows the result of the finished event to the player. + /// + /// The points which the allied forces scored. + /// The points which the illusion forces scored. + /// The result of each participant. + ValueTask ShowScoreTableAsync(byte alliedForcesPoints, byte illusionForcesPoints, IReadOnlyCollection<(string Name, byte MapNumber, IllusionTempleTeam Team, byte CharacterClass, int AddedExperience)> results); +} diff --git a/src/GameLogic/MiniGames/IIllusionTempleSkillEndedViewPlugin.cs b/src/GameLogic/MiniGames/IIllusionTempleSkillEndedViewPlugin.cs new file mode 100644 index 0000000000..c439d55188 --- /dev/null +++ b/src/GameLogic/MiniGames/IIllusionTempleSkillEndedViewPlugin.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; + +using MUnique.OpenMU.GameLogic.Views; + +/// +/// Interface of a view whose implementation informs about a special illusion temple skill (210 to 213) +/// wearing off on an object. +/// +public interface IIllusionTempleSkillEndedViewPlugin : IViewPlugIn +{ + /// + /// Announces that a skill's effect has ended on an object. + /// + /// The number of the skill (210 to 213). + /// The id of the affected object. + ValueTask ShowSkillEndedAsync(ushort skillNumber, ushort objectId); +} diff --git a/src/GameLogic/MiniGames/IIllusionTempleSkillPointUpdateViewPlugin.cs b/src/GameLogic/MiniGames/IIllusionTempleSkillPointUpdateViewPlugin.cs new file mode 100644 index 0000000000..0730f4e946 --- /dev/null +++ b/src/GameLogic/MiniGames/IIllusionTempleSkillPointUpdateViewPlugin.cs @@ -0,0 +1,20 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.MiniGames; + +using MUnique.OpenMU.GameLogic.Views; + +/// +/// Interface of a view whose implementation informs a player about his current skill point balance +/// during a running illusion temple event. +/// +public interface IIllusionTempleSkillPointUpdateViewPlugin : IViewPlugIn +{ + /// + /// Updates the skill points of the receiving player. + /// + /// The current skill point balance. + ValueTask UpdateSkillPointsAsync(byte skillPoints); +} diff --git a/src/GameLogic/MiniGames/IIllusionTempleSkillUsageResultViewPlugin.cs b/src/GameLogic/MiniGames/IIllusionTempleSkillUsageResultViewPlugin.cs new file mode 100644 index 0000000000..135092aa41 --- /dev/null +++ b/src/GameLogic/MiniGames/IIllusionTempleSkillUsageResultViewPlugin.cs @@ -0,0 +1,23 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.MiniGames; + +using MUnique.OpenMU.GameLogic.Views; + +/// +/// Interface of a view whose implementation informs a player about the result of a special illusion +/// temple skill (210 to 213) he requested to use. +/// +public interface IIllusionTempleSkillUsageResultViewPlugin : IViewPlugIn +{ + /// + /// Shows the result of a requested illusion temple skill. + /// + /// Whether the skill was used successfully. + /// The number of the skill (210 to 213). + /// The id of the player who used the skill. + /// The id of the target, or 0 if the skill didn't target anyone. + ValueTask ShowSkillUsageResultAsync(bool success, ushort skillNumber, ushort sourceId, ushort targetId); +} diff --git a/src/GameLogic/MiniGames/IIllusionTempleStateViewPlugin.cs b/src/GameLogic/MiniGames/IIllusionTempleStateViewPlugin.cs new file mode 100644 index 0000000000..b095b00022 --- /dev/null +++ b/src/GameLogic/MiniGames/IIllusionTempleStateViewPlugin.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; + +using MUnique.OpenMU.GameLogic.Views; + +/// +/// Interface of a view whose implementation informs about the state of a running illusion temple event. +/// +/// +/// In contrast to the other mini games, this update is not the same for all participants: it tells the +/// receiver which team he belongs to, and where his own team mates currently are - so it has to be +/// built per player. +/// +public interface IIllusionTempleStateViewPlugin : IViewPlugIn +{ + /// + /// Updates the state of the illusion temple event. + /// + /// The remaining time of the event. + /// The points which the allied forces scored so far. + /// The points which the illusion forces scored so far. + /// The team of the player who receives the update. + /// The team mates of the receiving player, with their current position. + /// + /// The player who currently carries the holy relic, with his current position - or null if + /// nobody currently carries it. + /// + ValueTask UpdateStateAsync( + TimeSpan remainingTime, + byte alliedForcesPoints, + byte illusionForcesPoints, + IllusionTempleTeam ownTeam, + IReadOnlyCollection<(ushort PlayerId, byte MapNumber, byte PositionX, byte PositionY)> teamMembers, + (ushort PlayerId, byte PositionX, byte PositionY)? relicCarrier); +} diff --git a/src/GameLogic/MiniGames/IShowIllusionTempleUserCountViewPlugIn.cs b/src/GameLogic/MiniGames/IShowIllusionTempleUserCountViewPlugIn.cs new file mode 100644 index 0000000000..989eff7dce --- /dev/null +++ b/src/GameLogic/MiniGames/IShowIllusionTempleUserCountViewPlugIn.cs @@ -0,0 +1,19 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.MiniGames; + +using MUnique.OpenMU.GameLogic.Views; + +/// +/// Interface for view plugins which show how many players are currently in each illusion temple. +/// +public interface IShowIllusionTempleUserCountViewPlugIn : IViewPlugIn +{ + /// + /// Shows how many players are currently in each of the six illusion temples. + /// + /// The player counts, indexed by temple (index 0 is temple 1). + ValueTask ShowUserCountAsync(IReadOnlyList userCounts); +} diff --git a/src/GameLogic/MiniGames/IllusionTempleContext.cs b/src/GameLogic/MiniGames/IllusionTempleContext.cs new file mode 100644 index 0000000000..f82b5c56d2 --- /dev/null +++ b/src/GameLogic/MiniGames/IllusionTempleContext.cs @@ -0,0 +1,1011 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +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.Inventory; +using MUnique.OpenMU.Pathfinding; +using MUnique.OpenMU.Persistence; + +namespace MUnique.OpenMU.GameLogic.MiniGames; + +/// +/// The context of an illusion temple game. +/// +/// +/// An illusion temple event works like that: +/// Up to 10 players enter one of the six temples (maps 45 to 50), each temple covering its own +/// character level bracket. Unlike the other mini games, this one is team based and player versus +/// player: the participants are split into two teams which fight each other for the whole match. +/// +/// A "Stone Statue" (NPC 380) holds the holy relic - only one is up at a time, randomly picked from +/// a pool of possible positions on the map. +/// * A player talks to the statue and becomes the relic's carrier; the statue breaks and disappears. +/// * The carrier is announced to everyone in the temple, so both teams know whom to chase. +/// * Carrying the relic to his own team's item storage (NPC 383 for the allied forces, 384 for the +/// illusion forces) scores a point for the carrier's team and spawns the next statue. +/// * When the carrier dies or leaves the event, the relic is dropped on the ground and can be picked +/// up again by anyone. +/// The game has a time limit. When it's up, the team with more points wins. +/// While the game is running, the clients get a cyclic state update with the remaining time, the +/// points of both teams and the positions of the own team's members. +/// +/// After the game ended, the players get their rewards: +/// +/// Experience: +/// Each player receives experience, which is reported back in the result packet, so that the client +/// can show it in the score board next to the player's name, team and class. +/// Items: +/// The winners request their reward explicitly after the result has been shown, and it's usually +/// granted as an item drop. +/// +/// Additionally, four special skills (210 to 213 - Order of Protection, Restraint, Tracking and +/// Weaken) can only be used inside this event. They are not paid with mana, but with an own pool of +/// skill points which is tracked per player and reported to the client separately. +/// +public sealed class IllusionTempleContext : MiniGameContext +{ + /// + /// The number of the stone statue, which holds the sacred relic during a match. + /// + private const short StatueNPC = 380; + + /// + /// How long the players see the "Preparation" state (still behind the arena barriers) before the + /// battle actually starts. + /// + private static readonly TimeSpan PreparationDuration = TimeSpan.FromSeconds(20); + + /// + /// How long it takes after a scored point for the next stone statue to spawn - matches the + /// original event's regen delay. + /// + private static readonly TimeSpan StatueRespawnDelay = TimeSpan.FromSeconds(5); + + /// + /// The lowest NPC number of the roaming "Illusion Sorc. Spirit" arena monsters, across all temples. + /// + private const short ArenaMonsterRangeStart = 386; + + /// + /// The highest NPC number of the roaming "Illusion Sorc. Spirit" arena monsters, across all temples. + /// + private const short ArenaMonsterRangeEnd = 399; + + /// + /// The skill points a player starts a match with. + /// + private const byte InitialSkillPoints = 10; + + /// + /// The maximum number of skill points a player can accumulate. + /// + private const byte MaximumSkillPoints = 90; + + /// + /// The skill points awarded for killing an opposing player. + /// + private const byte SkillPointsPerPlayerKill = 1; + + /// + /// The skill points awarded for killing one of the roaming arena monsters. + /// + private const byte SkillPointsPerMonsterKill = 2; + + /// + /// The number of the "Order of Protection" special skill. + /// + private const ushort OrderOfProtectionSkillNumber = 210; + + /// + /// The number of the "Restraint" special skill. + /// + private const ushort RestraintSkillNumber = 211; + + /// + /// The number of the "Tracking" special skill. + /// + private const ushort TrackingSkillNumber = 212; + + /// + /// The number of the "Weaken" special skill. + /// + private const ushort WeakenSkillNumber = 213; + + /// + /// The skill point cost of every special skill. + /// + private const byte SpecialSkillCost = 10; + + /// + /// The maximum distance from which the "Restraint" and "Weaken" special skills can target someone. + /// + private const int SpecialSkillMaximumDistance = 6; + + /// + /// The number of the magic effect which the "Order of Protection" special skill applies to its + /// caster. Matches the seeded magic effect of the same number - see + /// IllusionTempleInitializer.CreateSpecialSkillEffects. + /// + private const short OrderOfProtectionEffectNumber = 210; + + /// + /// The number of the magic effect which the "Restraint" special skill applies to its target. + /// Matches the seeded magic effect of the same number - see + /// IllusionTempleInitializer.CreateSpecialSkillEffects. + /// + private const short RestraintEffectNumber = 211; + + /// + /// Team A and Team B. + /// + private readonly ConcurrentDictionary _teams = new(); + + /// + /// The skill points of each participant, which fuel the event's special skills (210 to 213) instead + /// of mana. + /// + private readonly ConcurrentDictionary _skillPoints = new(); + + /// + /// The experience granted to a winner, so that it can be reported back in the result packet. + /// + private readonly ConcurrentDictionary _grantedExperience = new(); + + /// + /// The rank of every winner at the moment the game ended, kept around so that + /// can apply rank-restricted rewards whenever the player actually claims them. + /// + private readonly ConcurrentDictionary _winnerRanks = new(); + + /// + /// The players who already claimed their reward, so that clicking the result dialog's close button + /// more than once can't be used to farm item rewards. + /// + private readonly ConcurrentDictionary _claimedRewards = new(); + + /// + /// The spawn point of the allied forces, in the chamber which the map reserves for this team at the + /// north western corner. It's the target of the map's spawn gates 148 to 153, one per temple. + /// + /// + /// The chamber is closed off from the battle ground - the barriers between them are hardcoded at + /// client side and are only removed when the client is told that the battle started. So the players + /// wait here until the event sends that state, and walk into the arena afterwards. + /// + private Point alliedForcesCoordinates = new Point(141, 41); + + /// + /// The spawn point of the illusion forces, in the chamber at the south eastern corner. It's the + /// target of the map's spawn gates 154 to 159, one per temple. + /// + private Point illusionForcesCoordinates = new Point(194, 124); + + /// + /// Remaning Time of IT + /// + private TimeSpan _remainingTime; + + /// + /// The player who currently carries the holy relic, or null if nobody currently does. + /// + private Player? _relicCarrier; + + /// + /// The currently active stone statue, or null if none is currently spawned. + /// + private NonPlayerCharacter? _activeStatue; + + /// + /// 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 IllusionTempleContext(MiniGameMapKey key, MiniGameDefinition definition, IGameContext gameContext, IMapInitializer mapInitializer) + : base(key, definition, gameContext, mapInitializer) + { + } + + /// + /// + /// The whole event is a fight between two teams, so killing another participant must never be + /// punished as a regular player kill. + /// + public override bool AllowPlayerKilling => true; + + /// + /// Gets the score of both teams. + /// + public IllusionTempleScore Score { get; } = new(); + + /// + protected override TimeSpan RemainingTime => this._remainingTime; + + /// + /// + /// Returns a player of the leading team, so that the mini game definition's reward conditions + /// (which classify a winner by his party, see ) can tell winners and + /// losers apart. + /// + protected override Player? Winner => this.Score.LeadingTeam is { } leadingTeam + ? this._teams.FirstOrDefault(entry => entry.Value == leadingTeam).Key + : null; + + /// + /// + /// Two teams fighting each other need at least 2 players - configurable per temple via + /// in the admin panel. + /// + protected override int MinimumPlayerCount => this.Definition.MinimumPlayerCount > 0 ? this.Definition.MinimumPlayerCount : 2; + + /// + /// Gets the spawn gate of the player. + /// + /// The player. + /// The gate where the player is teleported to. + public override ExitGate? GetSpawnGate(Player player) + { + if (!this._teams.TryGetValue(player, out var team)) + { + return null; + } + + // The areas match the spawn gates which the map defines for the two teams: 148 to 153 for the + // allied forces and 154 to 159 for the illusion forces, one of each per temple. + var (start, end) = team == IllusionTempleTeam.AlliedForces + ? (new Point(141, 41), new Point(146, 45)) + : (new Point(194, 124), new Point(198, 127)); + + return new ExitGate + { + Map = this.Map.Definition, + X1 = start.X, + Y1 = start.Y, + X2 = end.X, + Y2 = end.Y, + }; + } + + /// + /// Handles a player talking to the stone statue (NPC 380) which holds the sacred relic. + /// + /// The player who talked to the statue. + public async ValueTask TalkToNpcStoneStatueAsync(Player player) + { + if (this._relicCarrier is not null) + { + // Somebody already carries the relic - the statue that granted it must already be gone. + return; + } + + var cursedCastleWater = player.GameContext.Configuration.Items.First(item => item.Group == 14 && item.Number == 64); + + var item = player.PersistenceContext.CreateNew(); + item.Definition = cursedCastleWater; + + var invIndex = player.Inventory?.CheckInvSpace(item); + if (invIndex is null) + { + await player.ShowBlueMessageAsync("Your Inventory is full!").ConfigureAwait(false); + return; + } + + await player.Inventory!.AddItemAsync(item).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.ItemAppearAsync(item)).ConfigureAwait(false); + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.IllusionTempleRelicPickedUpFormat), player.Name).ConfigureAwait(false); + + if (player.OpenedNpc is { } statue) + { + await statue.DisposeAsync().ConfigureAwait(false); + } + + this._activeStatue = null; + + this._relicCarrier = player; + await this.ForEachPlayerAsync(p => p.InvokeViewPlugInAsync( + vp => vp.ShowHolyItemRelicsAsync(player.Id, player.Name)).AsTask()).ConfigureAwait(false); + } + + /// + /// Handles a player talking to the team storage (NPC 383/384) which gets the sacred relic. + /// + /// NPC number. + /// The player who talked to the statue. + public async ValueTask TalkToNpcTeamStorageAsync(int npcNumber, Player player) + { + if (player != this._relicCarrier) + { + return; + } + + var relicItem = player.Inventory?.Items + .FirstOrDefault(i => i.Definition?.Group == 14 && i.Definition?.Number == 64); + + if (relicItem is null) + { + this._relicCarrier = null; + return; + } + + if (!this._teams.TryGetValue(player, out var playerTeam)) + { + return; + } + + if (npcNumber == 383 && playerTeam == IllusionTempleTeam.AlliedForces) + { + this.Score.IncreaseScore(IllusionTempleTeam.AlliedForces); + } + else if (npcNumber == 384 && playerTeam == IllusionTempleTeam.IllusionForces) + { + this.Score.IncreaseScore(IllusionTempleTeam.IllusionForces); + } + else + { + return; + } + + await player.Inventory!.RemoveItemAsync(relicItem).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.ItemDropResultAsync(relicItem.ItemSlot, true)).ConfigureAwait(false); + this._relicCarrier = null; + + // Push the new score to the clients right away, instead of waiting for the next tick of + // the cyclic state update (ShowRemainingTimeLoopAsync). + await this.UpdateStateForAllAsync().ConfigureAwait(false); + + // The next statue doesn't appear immediately - just like in the original event, there's a + // short delay after a scored point. This runs in the background so the delivering player's + // action isn't held up by it. + _ = Task.Run(async () => + { + try + { + await Task.Delay(StatueRespawnDelay, this.GameEndedToken).ConfigureAwait(false); + await this.SpawnRandomStatueAsync().ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // The event ended before the respawn delay elapsed. + } + }, this.GameEndedToken); + } + + /// + /// Spawns the stone statue at a random position from the map's pool of statue spawn points. + /// + private async ValueTask SpawnRandomStatueAsync() + { + try + { + var statueSpawns = this.Map.Definition.MonsterSpawns + .Where(spawn => spawn.MonsterDefinition?.Number == StatueNPC) + .ToList(); + if (statueSpawns.Count == 0) + { + this.Logger.LogWarning("No stone statue spawn points found on map {Map}.", this.Map.Definition.Name); + return; + } + + var spawnArea = statueSpawns[Rand.NextInt(0, statueSpawns.Count)]; + var statue = new NonPlayerCharacter(spawnArea, spawnArea.MonsterDefinition!, this.Map); + statue.Initialize(); + await this.Map.AddAsync(statue).ConfigureAwait(false); + statue.OnSpawn(); + + this._activeStatue = statue; + + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.IllusionTempleStatueSpawnedMessage)).ConfigureAwait(false); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Unexpected error while spawning the illusion temple stone statue."); + } + } + + /// + /// Handles a player's request to use one of the four special skills (210 to 213), which are paid + /// with the event's own skill point pool instead of mana. + /// + /// The player who requests to use the skill. + /// The number of the requested skill. + /// The map object index of the skill's target, if any. + public async ValueTask UseSkillAsync(Player player, ushort skillNumber, ushort targetObjectIndex) + { + if (!this._teams.ContainsKey(player)) + { + return; + } + + if (this._skillPoints.GetValueOrDefault(player, InitialSkillPoints) < SpecialSkillCost) + { + await player.InvokeViewPlugInAsync( + p => p.ShowSkillUsageResultAsync(false, skillNumber, player.Id, targetObjectIndex)).ConfigureAwait(false); + return; + } + + var target = player.CurrentMap?.GetObject(targetObjectIndex) as IAttackable; + var success = skillNumber switch + { + OrderOfProtectionSkillNumber => await this.UseOrderOfProtectionAsync(player).ConfigureAwait(false), + RestraintSkillNumber => await this.UseRestraintAsync(player, target).ConfigureAwait(false), + TrackingSkillNumber => await this.UseTrackingAsync(player).ConfigureAwait(false), + WeakenSkillNumber => await this.UseWeakenAsync(player, target).ConfigureAwait(false), + _ => false, + }; + + if (success) + { + await this.AwardSkillPointsAsync(player, -SpecialSkillCost).ConfigureAwait(false); + } + + await player.InvokeViewPlugInAsync( + p => p.ShowSkillUsageResultAsync(success, skillNumber, player.Id, target?.Id ?? 0)).ConfigureAwait(false); + } + + /// + /// Skill 210 - grants the caster a temporary damage reduction. + /// + private async ValueTask UseOrderOfProtectionAsync(Player player) + { + var effectDefinition = player.GameContext.Configuration.MagicEffects.FirstOrDefault(e => e.Number == OrderOfProtectionEffectNumber); + if (effectDefinition is null) + { + return false; + } + + var elements = effectDefinition.PowerUpDefinitions + .Select(powerUp => new MagicEffect.ElementWithTarget(player.Attributes!.CreateElement(powerUp), powerUp.TargetAttribute!)) + .ToArray(); + var duration = effectDefinition.Duration?.ConstantValue.Value ?? 15f; + var magicEffect = new MagicEffect(TimeSpan.FromSeconds(duration), effectDefinition, elements); + await player.MagicEffectList.AddEffectAsync(magicEffect).ConfigureAwait(false); + return true; + } + + /// + /// Skill 211 - roots the target in place for a while, within . + /// + private async ValueTask UseRestraintAsync(Player player, IAttackable? target) + { + if (target is null || target == player || player.GetDistanceTo(target) > SpecialSkillMaximumDistance) + { + return false; + } + + var effectDefinition = player.GameContext.Configuration.MagicEffects.FirstOrDefault(e => e.Number == RestraintEffectNumber); + if (effectDefinition is null) + { + return false; + } + + var elements = effectDefinition.PowerUpDefinitions + .Select(powerUp => new MagicEffect.ElementWithTarget(target.Attributes.CreateElement(powerUp), powerUp.TargetAttribute!)) + .ToArray(); + var duration = effectDefinition.Duration?.ConstantValue.Value ?? 15f; + var magicEffect = new MagicEffect(TimeSpan.FromSeconds(duration), effectDefinition, elements); + await target.MagicEffectList.AddEffectAsync(magicEffect).ConfigureAwait(false); + return true; + } + + /// + /// Skill 212 - teleports the caster next to the current relic carrier. Fails if the caster is + /// stunned or frozen, nobody currently carries the relic, or the caster is the carrier himself. + /// + private async ValueTask UseTrackingAsync(Player player) + { + if (this._relicCarrier is not { } carrier + || carrier == player + || player.Attributes![Stats.IsStunned] > 0 + || player.Attributes![Stats.IsFrozen] > 0) + { + return false; + } + + await player.MoveAsync(carrier.Position).ConfigureAwait(false); + return true; + } + + /// + /// Skill 213 - instantly halves the target's current shield, within . + /// + private ValueTask UseWeakenAsync(Player player, IAttackable? target) + { + if (target is null || target == player || player.GetDistanceTo(target) > SpecialSkillMaximumDistance) + { + return ValueTask.FromResult(false); + } + + target.Attributes[Stats.CurrentShield] /= 2; + return ValueTask.FromResult(true); + } + + /// + /// + /// Split the players into the two teams and place them at their team's spawn point. + /// + protected override async ValueTask OnGameStartAsync(ICollection players) + { + if (players.Count < this.MinimumPlayerCount) + { + // Not enough players made it into the arena (e.g. some left again during the countdown) - + // there's nobody to split into two teams, so the match can't be played. + this.FinishEvent(); + return; + } + + var playersArray = players.ToArray(); + + // Random players to add to the teams + for (var i = playersArray.Length - 1; i > 0; i--) + { + var j = Rand.NextInt(0, i + 1); + (playersArray[i], playersArray[j]) = (playersArray[j], playersArray[i]); + } + + var gameContext = playersArray[0].GameContext; + var alliedParty = gameContext.PartyManager.CreateParty(); + var illusionParty = gameContext.PartyManager.CreateParty(); + + for (var i = 0; playersArray.Length > i; i++) + { + // Adding player to team AlliedForces or IllusionForces + var player = playersArray[i]; + player.Party = null; + var team = i % 2 == 0 ? IllusionTempleTeam.AlliedForces : IllusionTempleTeam.IllusionForces; + if (!this._teams.TryAdd(player, team)) + { + this.Logger.LogWarning("Player {Player} was already assigned to a team.", player.Name); + continue; + } + + this._skillPoints[player] = InitialSkillPoints; + var party = team == IllusionTempleTeam.AlliedForces ? alliedParty : illusionParty; + if (!await party.AddAsync(player).ConfigureAwait(false)) + { + // The player still takes part in the event and is assigned to a team - he just doesn't + // show up in the party window of his team mates. + this.Logger.LogWarning( + "Player {Player} doesn't fit into the party of team {Team}: the party is limited to {MaxPartySize} members, while the event allows {MaximumPlayerCount} players.", + player.Name, + team, + party.MaxPartySize, + this.Definition.MaximumPlayerCount); + } + + await this.TeleportToStartCoordinatesAsync(team, player).ConfigureAwait(false); + } + + await base.OnGameStartAsync(players).ConfigureAwait(false); + + // The client keeps its event interface closed and the arena barriers up until it's told that + // the battle started - the barrier areas are hardcoded at client side, so this is the only way + // to open them. The two states are deliberately separated by a short delay, so the players + // actually get to see the preparation phase before the barriers drop - sending both back to + // back made the client skip straight to the battle without any noticeable wait. + var templeNumber = (byte)this.Definition.GameLevel; + await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync( + p => p.ChangeEventStateAsync(templeNumber, IllusionTempleEventStatus.Preparation)).AsTask()).ConfigureAwait(false); + + try + { + await Task.Delay(PreparationDuration, this.GameEndedToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // The event was already finished (e.g. not enough players) while the preparation phase + // was still running. + return; + } + + await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync( + p => p.ChangeEventStateAsync(templeNumber, IllusionTempleEventStatus.BattleStarted)).AsTask()).ConfigureAwait(false); + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.IllusionTempleBattleStartedMessage)).ConfigureAwait(false); + + await this.SpawnRandomStatueAsync().ConfigureAwait(false); + + await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync( + p => p.UpdateSkillPointsAsync(this._skillPoints.GetValueOrDefault(player, InitialSkillPoints))).AsTask()).ConfigureAwait(false); + + // The cyclic state update runs until the game ends - the token makes sure that it doesn't + // outlive the context and keeps sending to players who already left the event. + _ = Task.Run(async () => await this.ShowRemainingTimeLoopAsync(this.GameEndedToken).ConfigureAwait(false), this.GameEndedToken); + } + + /// + /// Awards skill points to a player, capped at , and informs him + /// about his new balance. + /// + /// The player who is awarded the points. + /// The number of points to award. + private async ValueTask AwardSkillPointsAsync(Player player, int amount) + { + if (!this._teams.ContainsKey(player)) + { + return; + } + + var newBalance = (byte)Math.Clamp(this._skillPoints.GetValueOrDefault(player, InitialSkillPoints) + amount, 0, MaximumSkillPoints); + this._skillPoints[player] = newBalance; + + await player.InvokeViewPlugInAsync(p => p.UpdateSkillPointsAsync(newBalance)).ConfigureAwait(false); + } + + /// + /// + /// When the dead player carried the relic, drop it so that it can be picked up again. When the + /// killer took part in the event on the opposing team, he's awarded skill points for the kill. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")] + protected override async void OnPlayerDied(object? sender, DeathInformation e) + { + base.OnPlayerDied(sender, e); + + try + { + if (sender is not Player deadPlayer) + { + return; + } + + await this.DropRelicIfCarriedByAsync(deadPlayer).ConfigureAwait(false); + + if (deadPlayer.CurrentMap?.GetObject(e.KillerId) is Player killer + && killer != deadPlayer + && this._teams.TryGetValue(killer, out var killerTeam) + && this._teams.TryGetValue(deadPlayer, out var deadPlayerTeam) + && killerTeam != deadPlayerTeam) + { + await this.AwardSkillPointsAsync(killer, SkillPointsPerPlayerKill).ConfigureAwait(false); + } + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Unexpected error while dropping the illusion temple relic after a player died."); + } + } + + /// + /// + /// Killing one of the roaming "Illusion Sorc. Spirit" arena monsters (386 to 399) grants the killer + /// skill points for the event's special skills. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")] + protected override async void OnMonsterDied(object? sender, DeathInformation e) + { + base.OnMonsterDied(sender, e); + + try + { + if (sender is not AttackableNpcBase monster + || monster.Definition.Number < ArenaMonsterRangeStart + || monster.Definition.Number > ArenaMonsterRangeEnd) + { + return; + } + + if (monster.CurrentMap?.GetObject(e.KillerId) is Player killer && this._teams.ContainsKey(killer)) + { + await this.AwardSkillPointsAsync(killer, SkillPointsPerMonsterKill).ConfigureAwait(false); + } + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Unexpected error while awarding illusion temple skill points for an arena monster kill."); + } + } + + /// + /// + /// When the player who left carried the relic (character switch, disconnect or leaving the event + /// on purpose), drop it so that it can be picked up again by the remaining participants. The player + /// himself is sent back to Devias and removed from his temporary event party, just like a player who + /// finishes a match normally in . If fewer than + /// players remain afterwards, the match can't continue and is ended right away. + /// + protected override async ValueTask OnObjectRemovedFromMapAsync((GameMap Map, ILocateable Object) args) + { + if (args.Object is Player player) + { + await this.DropRelicIfCarriedByAsync(player).ConfigureAwait(false); + + if (player.Party is { } party) + { + await party.KickMySelfAsync(player).ConfigureAwait(false); + } + + var devias = player.GameContext.Configuration.Maps.First(map => map.Number == 2); + await player.WarpToAsync(new ExitGate + { + Map = devias, + X1 = 197, + Y1 = 35, + X2 = 218, + Y2 = 50, + }).ConfigureAwait(false); + + // Otherwise he'd keep showing up as an alive team mate on the mini map of his former team. + this._teams.TryRemove(player, out _); + this._skillPoints.TryRemove(player, out _); + } + + await base.OnObjectRemovedFromMapAsync(args).ConfigureAwait(false); + + if (args.Object is Player && this.PlayerCount < this.MinimumPlayerCount) + { + this.FinishEvent(); + } + } + + /// + /// Drops the holy relic on the ground, if the specified player currently carries it. Works for any + /// reason the player stops carrying it that isn't already a ground drop by itself (death, leaving + /// the event) - a voluntary drop through the normal drop-item action is instead caught by + /// , which is the single place that actually clears + /// and announces it, so that both paths behave the same and don't + /// announce the drop twice. + /// + /// The player who might carry the relic. + private async ValueTask DropRelicIfCarriedByAsync(Player player) + { + if (player != this._relicCarrier) + { + return; + } + + var relicItem = player.Inventory?.Items + .FirstOrDefault(i => i.Definition?.Group == 14 && i.Definition?.Number == 64); + + if (relicItem is null || player.CurrentMap is not { } map) + { + this._relicCarrier = null; + return; + } + + var droppedItem = new DroppedItem(relicItem, player.Position, map, player); + await map.AddAsync(droppedItem).ConfigureAwait(false); + await player.Inventory!.RemoveItemAsync(relicItem).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.ItemDropResultAsync(relicItem.ItemSlot, true)).ConfigureAwait(false); + } + + /// + /// + /// Catches every way the holy relic can end up on the ground - a voluntary drop through the normal + /// drop-item action, as well as the ones triggered by (death, + /// leaving the event) - and is the single place that clears and announces + /// it, so a manual drop is tracked exactly like the other cases. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")] + protected override async void OnItemDroppedOnMap(DroppedItem item) + { + base.OnItemDroppedOnMap(item); + + try + { + if (this._relicCarrier is not { } carrier + || item.Item.Definition?.Group != 14 + || item.Item.Definition?.Number != 64) + { + return; + } + + this._relicCarrier = null; + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.IllusionTempleRelicDroppedFormat), carrier.Name).ConfigureAwait(false); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Unexpected error while handling a dropped illusion temple relic."); + } + } + + /// + /// Will be called when an item has been picked up by player. + /// + /// The event parameters. + protected async override ValueTask OnPlayerPickedUpItemAsync((Player Picker, ILocateable DroppedItem) args) + { + if (this._relicCarrier is null + && args.DroppedItem is DroppedItem droppedItem + && droppedItem.Item.Definition?.Group == 14 + && droppedItem.Item.Definition?.Number == 64) + { + this._relicCarrier = args.Picker; + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.IllusionTempleRelicPickedUpFormat), args.Picker.Name).ConfigureAwait(false); + + // The client needs to be told who the new carrier is - otherwise it keeps showing the + // previous one (or nobody) as the hero on its mini map. + await this.ForEachPlayerAsync(p => p.InvokeViewPlugInAsync( + vp => vp.ShowHolyItemRelicsAsync(args.Picker.Id, args.Picker.Name)).AsTask()).ConfigureAwait(false); + } + } + + /// + /// + /// Show the individual result of the player - his team, its score and the gained experience. + /// + protected async override ValueTask ShowScoreAsync(Player player) + { + var results = this._teams + .Select(entry => ( + entry.Key.Name, + MapNumber: (byte)this.Map.Definition.Number, + Team: entry.Value, + CharacterClass: (byte)(entry.Key.SelectedCharacter?.CharacterClass?.Number ?? 0), + AddedExperience: this._grantedExperience.GetValueOrDefault(entry.Key))) + .ToList(); + + await player.InvokeViewPlugInAsync(p => p.ShowScoreTableAsync(this.Score.AlliedForcesScore, this.Score.IllusionForcesScore, results)).ConfigureAwait(false); + await base.ShowScoreAsync(player).ConfigureAwait(false); + } + + /// + /// Handles a player claiming his reward after the result dialog has been shown, in reaction to the + /// IllusionTempleRewardRequest (0xBF05) packet - the client sends it when the player clicks the + /// "Close" button on the result dialog. Experience has already been granted automatically in + /// , so this only grants the remaining reward types (e.g. an item drop) + /// to winners, and finally warps the requesting player to Devias - regardless of whether he won, + /// lost, or already claimed his reward before. + /// + /// The player who claims his reward. + public async ValueTask ClaimRewardAsync(Player player) + { + if (this._claimedRewards.TryAdd(player, true) + && this._teams.TryGetValue(player, out var team) + && this.Score.LeadingTeam == team) + { + var rank = this._winnerRanks.GetValueOrDefault(player, 1); + var remainingRewards = this.Definition.Rewards.Where(r => + r.RewardType is not (MiniGameRewardType.Experience or MiniGameRewardType.ExperiencePerRemainingSeconds) + && this.DoesRewardApply(player, rank, r)); + foreach (var reward in remainingRewards) + { + await this.GiveRewardAsync(player, reward).ConfigureAwait(false); + } + } + + var devias = player.GameContext.Configuration.Maps.First(map => map.Number == 2); + await player.WarpToAsync(new ExitGate + { + Map = devias, + X1 = 197, + Y1 = 35, + X2 = 218, + Y2 = 50, + }).ConfigureAwait(false); + } + + /// + /// + /// The rewards are only granted to the members of the leading team. The success flags of the mini + /// game definition can't decide that on their own, because they classify a winner by his party - + /// and this event doesn't allow parties. So the winners are determined here by their team, and the + /// definition only decides what they receive. On a draw, nobody wins and nobody is rewarded. + /// Experience is granted right away, so it can be reported in the result packet - the remaining + /// reward types (e.g. an item drop) are granted later, when the player actually claims them via + /// . + /// + protected override async ValueTask GameEndedAsync(ICollection finishers) + { + if (this.Score.LeadingTeam is { } winningTeam) + { + var winners = finishers + .Where(player => this._teams.TryGetValue(player, out var team) && team == winningTeam) + .ToList(); + + var rank = 0; + foreach (var winner in winners) + { + rank++; + this._winnerRanks[winner] = rank; + + var experienceRewards = this.Definition.Rewards + .Where(r => r.RewardType is MiniGameRewardType.Experience or MiniGameRewardType.ExperiencePerRemainingSeconds + && this.DoesRewardApply(winner, rank, r)) + .ToList(); + this._grantedExperience[winner] = experienceRewards.Sum(r => r.RewardAmount); + + foreach (var reward in experienceRewards) + { + await this.GiveRewardAsync(winner, reward).ConfigureAwait(false); + } + } + } + + // base.GameEndedAsync() shows the score table to every finisher (via ShowScoreAsync), which + // reads this._teams - so it has to run before anyone leaves the map. Warping a player off this + // map fires OnObjectRemovedFromMapAsync, which removes him from _teams; doing that first would + // leave the score table empty for everyone (and the client, unable to find itself in the + // now-empty participant list, appears to fall back to declaring both sides victorious). Players + // stay on this map afterward - they leave individually via ClaimRewardAsync when they close their + // own result dialog, or automatically once the base class's exit duration elapses + // (MiniGameContext.ShutdownGameAsync -> MovePlayersToSafezoneAsync). + await base.GameEndedAsync(finishers).ConfigureAwait(false); + } + + private async ValueTask TeleportToStartCoordinatesAsync(IllusionTempleTeam team, Player player) + { + var cordinatesAlliedForces = this.alliedForcesCoordinates; + var illusionForcesCoordinates = this.illusionForcesCoordinates; + if (team == IllusionTempleTeam.AlliedForces) + { + cordinatesAlliedForces += new Point(1, 0); // every player on differend point (x,y) + await player.MoveAsync(cordinatesAlliedForces).ConfigureAwait(false); + } + else + { + illusionForcesCoordinates += new Point(1, 0); // every player on differend point (x,y) + await player.MoveAsync(illusionForcesCoordinates).ConfigureAwait(false); + } + } + + private async ValueTask ShowRemainingTimeLoopAsync(CancellationToken cancellationToken) + { + try + { + var timerInterval = TimeSpan.FromSeconds(1); + using var timer = new PeriodicTimer(timerInterval); + var maximumGameDuration = this.Definition.GameDuration; + this._remainingTime = maximumGameDuration; + + await this.UpdateStateForAllAsync().ConfigureAwait(false); + while (!cancellationToken.IsCancellationRequested + && this._remainingTime >= TimeSpan.Zero + && await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + this._remainingTime = this._remainingTime.Subtract(timerInterval); + await this.UpdateStateForAllAsync().ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Expected exception when the game ends before running into the timeout. + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Unexpected error during update of the illusion temple state: {0}", ex.Message); + } + } + + private ValueTask UpdateStateForAllAsync() + { + return this.ForEachPlayerAsync(player => this.UpdateStateAsync(player).AsTask()); + } + + private ValueTask UpdateStateAsync(Player player) + { + if (!this._teams.TryGetValue(player, out var ownTeam)) + { + // The player is on the map, but wasn't assigned to a team - there is nothing to tell him. + return ValueTask.CompletedTask; + } + + // Only the own team is reported: the client shows these players on its mini map, and knowing + // where the enemies are would take the whole hunt out of the event. + var teamMembers = this._teams + .Where(entry => entry.Value == ownTeam && entry.Key != player) + .Select(entry => ( + PlayerId: entry.Key.Id, + MapNumber: (byte)(entry.Key.CurrentMap?.Definition.Number ?? 0), + PositionX: entry.Key.Position.X, + PositionY: entry.Key.Position.Y)) + .ToList(); + + (ushort PlayerId, byte PositionX, byte PositionY)? relicCarrier = this._relicCarrier is { } carrier + ? (carrier.Id, carrier.Position.X, carrier.Position.Y) + : null; + + return player.InvokeViewPlugInAsync( + p => p.UpdateStateAsync( + this._remainingTime, + this.Score.AlliedForcesScore, + this.Score.IllusionForcesScore, + ownTeam, + teamMembers, + relicCarrier)); + } +} diff --git a/src/GameLogic/MiniGames/IllusionTempleScore.cs b/src/GameLogic/MiniGames/IllusionTempleScore.cs new file mode 100644 index 0000000000..d469fee485 --- /dev/null +++ b/src/GameLogic/MiniGames/IllusionTempleScore.cs @@ -0,0 +1,86 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.MiniGames; + +using System.Threading; + +/// +/// The score of an illusion temple game. +/// +/// +/// The counters are kept as integers, so that concurrent scoring can't wrap them around, while the +/// game client can only show a byte per team - the properties clamp them accordingly. +/// +public class IllusionTempleScore +{ + private int _alliedForcesScore; + + private int _illusionForcesScore; + + /// + /// Gets the score of the allied forces. + /// + public byte AlliedForcesScore => (byte)Math.Min(byte.MaxValue, this._alliedForcesScore); + + /// + /// Gets the score of the illusion forces. + /// + public byte IllusionForcesScore => (byte)Math.Min(byte.MaxValue, this._illusionForcesScore); + + /// + /// The minimum score a team needs to be declared the winner - a single relic delivered ahead of the + /// other team (e.g. 1:0) isn't enough on its own and counts as a draw, just like in the original + /// event. + /// + private const int MinimumWinningScore = 2; + + /// + /// Gets the team which is currently in the lead, or null, if neither team has both scored at + /// least points and more than the other team. + /// + public IllusionTempleTeam? LeadingTeam + { + get + { + if (this._alliedForcesScore >= MinimumWinningScore && this._alliedForcesScore > this._illusionForcesScore) + { + return IllusionTempleTeam.AlliedForces; + } + + if (this._illusionForcesScore >= MinimumWinningScore && this._illusionForcesScore > this._alliedForcesScore) + { + return IllusionTempleTeam.IllusionForces; + } + + return null; + } + } + + /// + /// Gets the score of the specified team. + /// + /// The team. + /// The score of the team. + public byte GetScore(IllusionTempleTeam team) => team == IllusionTempleTeam.AlliedForces + ? this.AlliedForcesScore + : this.IllusionForcesScore; + + /// + /// Increases the score of the specified team. + /// + /// The team which scored. + /// The value by which the score is increased. + public void IncreaseScore(IllusionTempleTeam team, int value = 1) + { + if (team == IllusionTempleTeam.AlliedForces) + { + Interlocked.Add(ref this._alliedForcesScore, value); + } + else + { + Interlocked.Add(ref this._illusionForcesScore, value); + } + } +} diff --git a/src/GameLogic/MiniGames/IllusionTempleTeam.cs b/src/GameLogic/MiniGames/IllusionTempleTeam.cs new file mode 100644 index 0000000000..1f3fa585bd --- /dev/null +++ b/src/GameLogic/MiniGames/IllusionTempleTeam.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; + +/// +/// Defines the team of a illusion temple. +/// +public enum IllusionTempleTeam +{ + /// + /// The first team. + /// + AlliedForces, + + /// + /// The second team. + /// + IllusionForces, +} \ No newline at end of file diff --git a/src/GameLogic/MiniGames/MiniGameContext.cs b/src/GameLogic/MiniGames/MiniGameContext.cs index d312e5d027..769444bf69 100644 --- a/src/GameLogic/MiniGames/MiniGameContext.cs +++ b/src/GameLogic/MiniGames/MiniGameContext.cs @@ -34,7 +34,7 @@ public class MiniGameContext : AsyncDisposable, IEventStateProvider private readonly ConcurrentDictionary _currentSpawnWaves = new(); private readonly List _remainingEvents = new(); - + private Stopwatch? _elapsedTimeSinceStart; /// @@ -88,6 +88,7 @@ public MiniGameContext(MiniGameMapKey key, MiniGameDefinition definition, IGameC /// public bool IsEventRunning => this.State == MiniGameState.Playing; + /// /// Gets the player count. @@ -137,9 +138,10 @@ public int PlayerCount protected IDropGenerator DropGenerator { get; set; } /// - /// Gets the minimum player count to start the game. + /// Gets the minimum player count to start the game. Reads + /// when it's configured (greater than 0); otherwise falls back to the game type's built-in default. /// - protected virtual int MinimumPlayerCount => 1; + protected virtual int MinimumPlayerCount => this.Definition.MinimumPlayerCount > 0 ? this.Definition.MinimumPlayerCount : 1; /// /// Tries to enter the mini game. It will fail, if it's full, of if it's not in an open state. @@ -202,6 +204,13 @@ public virtual bool IsSkillAllowed(Skill skill, Player attacker, IAttackable tar return true; } + /// + /// Gets spown gate + /// + /// + /// + public virtual ExitGate? GetSpawnGate(Player player) => null; + /// public override string ToString() { @@ -558,7 +567,13 @@ protected async ValueTask ShowGoldenMessageAsync(string messageKey, params objec await this.ForEachPlayerAsync(player => player.ShowLocalizedGoldenMessageAsync(messageKey, args).AsTask()).ConfigureAwait(false); } - private async ValueTask<(int BonusScore, int GivenMoney)> GiveRewardAsync(Player player, MiniGameReward reward) + /// + /// Gives a single reward to the player. + /// + /// The player who should receive the reward. + /// The reward. + /// The bonus score and the given money. + protected async ValueTask<(int BonusScore, int GivenMoney)> GiveRewardAsync(Player player, MiniGameReward reward) { switch (reward.RewardType) { @@ -740,6 +755,11 @@ private async ValueTask RunGameAsync(CancellationToken cancellationToken) await this.ForEachPlayerAsync(async player => player.TryAddMoney(this.Definition.EntranceFee)).ConfigureAwait(false); } + // The players who did enter would otherwise be stuck on the event map forever - the + // game never starts, so StartAsync/StopAsync/ShutdownGameAsync (which would normally + // move them back out) never run either. + await this.MovePlayersToSafezoneAsync().ConfigureAwait(false); + return; } @@ -871,7 +891,7 @@ private async ValueTask MovePlayersToSafezoneAsync() } } - private bool DoesRewardApply(Player player, int playerRank, MiniGameReward reward) + protected bool DoesRewardApply(Player player, int playerRank, MiniGameReward reward) { if (reward.Rank is not null && reward.Rank != playerRank) { diff --git a/src/GameLogic/PlayerActions/Craftings/IllusionTempleTicketCrafting.cs b/src/GameLogic/PlayerActions/Craftings/IllusionTempleTicketCrafting.cs index 0451c4f33d..8599a190fa 100644 --- a/src/GameLogic/PlayerActions/Craftings/IllusionTempleTicketCrafting.cs +++ b/src/GameLogic/PlayerActions/Craftings/IllusionTempleTicketCrafting.cs @@ -15,7 +15,7 @@ public class IllusionTempleTicketCrafting : BaseEventTicketCrafting /// Initializes a new instance of the class. /// public IllusionTempleTicketCrafting() - : base("Scroll of Blood", "Old Scroll", "Illusion Sorcerer Covenant") + : base("Illusion Sorcerer Covenant", "Old Scroll", "Scroll of Blood") { } diff --git a/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs b/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs index 2d180fc50d..5f2abc2388 100644 --- a/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs +++ b/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs @@ -11,6 +11,7 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.MiniGames; using MUnique.OpenMU.GameLogic.PlayerActions.PlayerStore; using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; using MUnique.OpenMU.GameLogic.Views.Inventory; +using MUnique.OpenMU.GameLogic.Views.NPC; /// /// Player action which implements entering a mini game. @@ -39,6 +40,7 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam || (miniGameDefinition.RequiresMasterClass && !player.SelectedCharacter.CharacterClass.IsMasterClass) || player.CurrentMiniGame is not null) { + await ShowRefusalAsync(player, $"You can't enter this event.").ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.Failed)).ConfigureAwait(false); return; } @@ -50,36 +52,42 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam var requiresMasterLevel = miniGameDefinition.RequiresMasterClass; if (characterLevel < minLevel || (requiresMasterLevel && player.SelectedCharacter?.CharacterClass?.IsMasterClass is not true)) { + await ShowRefusalAsync(player, $"Your level is too low. You need to be at least level {minLevel} to enter this event.").ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.CharacterLevelTooLow)).ConfigureAwait(false); return; } if (characterLevel > maxLevel) { + await ShowRefusalAsync(player, $"Your level is too high. You need to be at most level {maxLevel} to enter this event.").ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.CharacterLevelTooHigh)).ConfigureAwait(false); return; } if (!this.CheckTicketItem(miniGameDefinition, player, gameTicketInventoryIndex, out var ticketItem)) { + await ShowRefusalAsync(player, $"You need a ticket to enter this event.").ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.Failed)).ConfigureAwait(false); return; } if (!this.CheckEntranceFee(miniGameDefinition, player, out var entranceFee)) { + await ShowRefusalAsync(player, $"You need {miniGameDefinition.EntranceFee} zen to enter this event.").ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.NotEnoughMoney)).ConfigureAwait(false); return; } if (!this.CheckPlayerKillState(miniGameDefinition, player)) { + await ShowRefusalAsync(player, $"Killers can`t enter!").ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.PlayerKillerCantEnter)).ConfigureAwait(false); return; } if (player.GuildWarContext is { State: GuildWarState.Started or GuildWarState.Requested }) { + await ShowRefusalAsync(player, "You can't enter this event during a guild war.").ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.Failed)).ConfigureAwait(false); return; } @@ -91,6 +99,7 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam if (miniGameStrategy is not null && await miniGameStrategy.GetDurationUntilNextStartAsync(player.GameContext, miniGameDefinition).ConfigureAwait(false) != TimeSpan.Zero) { + await ShowRefusalAsync(player, $"{miniGameDefinition.Name} is not open right now.").ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.NotOpen)).ConfigureAwait(false); return; } @@ -140,6 +149,26 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam } } + /// + /// Shows the reason why the player can't enter the mini game. The client only knows a fixed set of + /// refusal codes per event, and they don't cover every case the server checks - so the reason is + /// told in plain words, by the npc the player is talking to, or as a system message when the entry + /// was requested without an open npc dialog. + /// + /// The player which tried to enter. + /// The reason, in plain words. + private static async ValueTask ShowRefusalAsync(Player player, string reason) + { + if (player.OpenedNpc is { } npc) + { + await player.InvokeViewPlugInAsync(p => p.ShowMessageOfObjectAsync(reason, npc)).ConfigureAwait(false); + } + else + { + await player.ShowBlueMessageAsync(reason).ConfigureAwait(false); + } + } + private bool CheckPlayerKillState(MiniGameDefinition miniGameDefinition, Player player) { if (miniGameDefinition.ArePlayerKillersAllowedToEnter) diff --git a/src/GameLogic/PlayerActions/TalkNpcAction.cs b/src/GameLogic/PlayerActions/TalkNpcAction.cs index 05009e24fb..1c2d7dc9e7 100644 --- a/src/GameLogic/PlayerActions/TalkNpcAction.cs +++ b/src/GameLogic/PlayerActions/TalkNpcAction.cs @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.PlayerActions.Quests; using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.GameLogic.Views.Guild; using MUnique.OpenMU.GameLogic.Views.NPC; @@ -78,6 +79,27 @@ private async ValueTask ShowDialogOfOpenedNpcAsync(Player player) { await bloodCastle.TalkToNpcArchangelAsync(player).ConfigureAwait(false); } + else if (player.CurrentMiniGame is IllusionTempleContext illusionTemple) + { + switch (player.OpenedNpc.Definition.Number) + { + case 380: + // Stone Statue + await illusionTemple.TalkToNpcStoneStatueAsync(player).ConfigureAwait(false); + break; + case 383: + // Alliance Item Storage, or Illusion Item Storage on a client where 384 works. + await illusionTemple.TalkToNpcTeamStorageAsync(player.OpenedNpc.Definition.Number, player).ConfigureAwait(false); + break; + case 384: + // Alliance Item Storage, or Illusion Item Storage on a client where 384 works. + await illusionTemple.TalkToNpcTeamStorageAsync(player.OpenedNpc.Definition.Number, player).ConfigureAwait(false); + break; + default: + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.TalkingNotImplementedFormat), npcStats.Number, npcStats.Designation).ConfigureAwait(false); + break; + } + } else { await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.TalkingNotImplementedFormat), npcStats.Number, npcStats.Designation).ConfigureAwait(false); @@ -133,6 +155,16 @@ private async ValueTask ShowDialogOfOpenedNpcAsync(Player player) case NpcWindow.RemoveJohOption: await player.InvokeViewPlugInAsync(p => p.OpenNpcWindowAsync(npcStats.NpcWindow)).ConfigureAwait(false); break; + case NpcWindow.IllusionTemple: + await player.InvokeViewPlugInAsync(p => p.OpenNpcWindowAsync(npcStats.NpcWindow)).ConfigureAwait(false); + await this.ShowIllusionTempleUserCountsAsync(player).ConfigureAwait(false); + + // The client doesn't tell the server when this window is closed, so the state is reset + // right away - otherwise the player would be stuck in the NpcDialogOpened state and + // couldn't open the window a second time. The npc itself stays assigned, so that the + // entry can still report its refusals as a message of the npc. + await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false); + break; default: await player.InvokeViewPlugInAsync(p => p.OpenNpcWindowAsync(npcStats.NpcWindow)).ConfigureAwait(false); break; @@ -144,6 +176,37 @@ private async ValueTask ShowDialogOfOpenedNpcAsync(Player player) } } + /// + /// Sends the number of players of each illusion temple to the client, so that it can show them + /// in the entrance dialog next to the temples the player can enter. + /// + /// The player which opened the illusion temple dialog. + private async ValueTask ShowIllusionTempleUserCountsAsync(Player player) + { + if (player.GameContext.PlugInManager.GetStrategy(MiniGameType.IllusionTemple) is not { } startPlugIn) + { + // The event is not enabled on this server - the dialog then just shows no members at all. + return; + } + + var definitions = player.GameContext.Configuration.MiniGameDefinitions + .Where(definition => definition.Type == MiniGameType.IllusionTemple) + .OrderBy(definition => definition.GameLevel) + .ToList(); + + var userCounts = new List(definitions.Count); + foreach (var definition in definitions) + { + // GetMiniGameContextAsync returns null when the event isn't running - in contrast to + // IGameContext.GetMiniGameAsync, which would create a context and thereby start all + // six temples just by asking for their player count. + var miniGameContext = await startPlugIn.GetMiniGameContextAsync(player.GameContext, definition).ConfigureAwait(false); + userCounts.Add(miniGameContext?.PlayerCount ?? 0); + } + + await player.InvokeViewPlugInAsync(p => p.ShowUserCountAsync(userCounts)).ConfigureAwait(false); + } + private async ValueTask ShowLegacyQuestDialogAsync(Player player) { var quests = player.OpenedNpc!.Definition.Quests diff --git a/src/GameLogic/PlayerMapTransitions.cs b/src/GameLogic/PlayerMapTransitions.cs index 8948d927c4..079a84d130 100644 --- a/src/GameLogic/PlayerMapTransitions.cs +++ b/src/GameLogic/PlayerMapTransitions.cs @@ -190,7 +190,15 @@ public async ValueTask RespawnAtAsync(ExitGate gate) { // Older clients use a separate packet for the respawn, while newer don't. // It requires a slightly different logic. - player.CurrentMap = await player.GameContext.GetMapAsync(player.SelectedCharacter!.CurrentMap!.Number.ToUnsigned()).ConfigureAwait(false) ?? throw new InvalidOperationException("Current map not found."); + var targetMapNumber = player.SelectedCharacter!.CurrentMap!.Number; + + // A mini game runs on its own instance of the map, which can't be resolved by the map number - + // that one always returns the regular instance. Without this, a player who respawns during a + // mini game lands on an empty copy of the map, invisible to the other participants. + player.CurrentMap = (player.CurrentMiniGame?.Map is { } miniGameMap && miniGameMap.Definition.Number == targetMapNumber + ? miniGameMap + : await player.GameContext.GetMapAsync(targetMapNumber.ToUnsigned()).ConfigureAwait(false)) + ?? throw new InvalidOperationException("Current map not found."); await respawnPlugIn.RespawnAsync().ConfigureAwait(false); await player.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.EnteredWorld).ConfigureAwait(false); player.IsAlive = true; diff --git a/src/GameLogic/PlugIns/MiniGameSpawnGatePlugIn.cs b/src/GameLogic/PlugIns/MiniGameSpawnGatePlugIn.cs new file mode 100644 index 0000000000..41e5a814ce --- /dev/null +++ b/src/GameLogic/PlugIns/MiniGameSpawnGatePlugIn.cs @@ -0,0 +1,36 @@ +// +// 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; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin which spawns a player of a running mini game at the spot the game assigns to him - for +/// example his team's chamber in the illusion temple - instead of the safezone. +/// +[PlugIn] +[Display(Name = nameof(MiniGameSpawnGatePlugIn), Description = "Spawns a player of a running mini game at the spot the game assigns to him.")] +[Guid("A3F5C7D9-1B4E-4A28-9C6D-0E8B2F5A7C41")] +public class MiniGameSpawnGatePlugIn : IPlayerSpawnGateSelectionPlugIn +{ + /// + public ValueTask SelectSpawnGateAsync(Player player, SpawnGateSelectionArgs args) + { + if (args.Gate is not null) + { + return ValueTask.CompletedTask; + } + + if (player.CurrentMiniGame is { State: MiniGameState.Playing } miniGame + && miniGame.GetSpawnGate(player) is { } miniGameGate) + { + args.Gate = miniGameGate; + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/GameLogic/Properties/PlayerMessage.Designer.cs b/src/GameLogic/Properties/PlayerMessage.Designer.cs index abf9e627bd..a243b69c13 100644 --- a/src/GameLogic/Properties/PlayerMessage.Designer.cs +++ b/src/GameLogic/Properties/PlayerMessage.Designer.cs @@ -1508,7 +1508,52 @@ public static string TalkingNotImplementedFormat { return ResourceManager.GetString("TalkingNotImplementedFormat", resourceCulture); } } - + + /// + /// Looks up a localized string similar to {0} has taken the holy relic!. + /// + public static string IllusionTempleRelicPickedUpFormat { + get { + return ResourceManager.GetString("IllusionTempleRelicPickedUpFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} dropped the holy relic!. + /// + public static string IllusionTempleRelicDroppedFormat { + get { + return ResourceManager.GetString("IllusionTempleRelicDroppedFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Score - Allied Forces: {0} | Illusion Forces: {1}. + /// + public static string IllusionTempleScoreFormat { + get { + return ResourceManager.GetString("IllusionTempleScoreFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A new sacred relic has appeared!. + /// + public static string IllusionTempleStatueSpawnedMessage { + get { + return ResourceManager.GetString("IllusionTempleStatueSpawnedMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The battle has begun!. + /// + public static string IllusionTempleBattleStartedMessage { + get { + return ResourceManager.GetString("IllusionTempleBattleStartedMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Your account has been temporarily banned by a game master.. /// diff --git a/src/GameLogic/Properties/PlayerMessage.resx b/src/GameLogic/Properties/PlayerMessage.resx index cec2464b46..3e32bc51e6 100644 --- a/src/GameLogic/Properties/PlayerMessage.resx +++ b/src/GameLogic/Properties/PlayerMessage.resx @@ -423,6 +423,21 @@ Talking to this NPC ({0}, {1}) is not implemented yet. + + {0} has taken the holy relic! + + + {0} dropped the holy relic! + + + Score - Allied Forces: {0} | Illusion Forces: {1} + + + A new sacred relic has appeared! + + + The battle has begun! + {0} has destroyed the Crystal Statue! diff --git a/src/GameServer/MessageHandler/MiniGames/IllusionTempleEnterHandlerPlugin.cs b/src/GameServer/MessageHandler/MiniGames/IllusionTempleEnterHandlerPlugin.cs new file mode 100644 index 0000000000..848359e386 --- /dev/null +++ b/src/GameServer/MessageHandler/MiniGames/IllusionTempleEnterHandlerPlugin.cs @@ -0,0 +1,68 @@ +// +// 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 Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel; +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 illusion temple enter request packets. +/// +/// +/// The packet belongs to the 0xBF group, which is dispatched by the . +/// Therefore this is a sub packet handler which is selected by the sub code, and not a handler of its own. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.IllusionTempleEnterHandlerPlugIn_Name), Description = nameof(PlugInResources.IllusionTempleEnterHandlerPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("D4F0076F-86D2-4712-B9FD-6B1C58B11969")] +[BelongsToGroup(MuHelperGroupHandler.GroupKey)] +internal class IllusionTempleEnterHandlerPlugIn : ISubPacketHandlerPlugIn +{ + /// + /// The game action which contains the logic to enter the mini game. + /// + private readonly EnterMiniGameAction _enterAction = new(); + + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => IllusionTempleEnterRequest.SubCode; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + if (packet.Length < IllusionTempleEnterRequest.Length + || player.SelectedCharacter?.CharacterClass is null) + { + return; + } + + IllusionTempleEnterRequest request = packet; + var definitions = player.GameContext.Configuration.MiniGameDefinitions + .Where(def => def.Type == MiniGameType.IllusionTemple) + .ToList(); + + // Despite its name, the client sends the number of the temple (1 to 6) here, which corresponds + // to the game level - not the number of the game map. The lookup by map number is kept as a + // fallback, in case another client version sends the actual map number (45 to 50). + var definition = definitions.FirstOrDefault(def => def.GameLevel == request.MapNumber) + ?? definitions.FirstOrDefault(def => def.Entrance?.Map?.Number == request.MapNumber); + var ticketIndex = request.ItemSlot - InventoryConstants.EquippableSlotsCount; + + await this._enterAction.TryEnterMiniGameAsync( + player, + MiniGameType.IllusionTemple, + definition?.GameLevel ?? request.MapNumber, + (byte)ticketIndex).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/GameServer/MessageHandler/MiniGames/IllusionTempleRewardRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/MiniGames/IllusionTempleRewardRequestHandlerPlugIn.cs new file mode 100644 index 0000000000..b7403e536c --- /dev/null +++ b/src/GameServer/MessageHandler/MiniGames/IllusionTempleRewardRequestHandlerPlugIn.cs @@ -0,0 +1,44 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.MessageHandler.MiniGames; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.GameServer.MessageHandler.MuHelper; +using MUnique.OpenMU.Network.Packets.ClientToServer; +using MUnique.OpenMU.PlugIns; + +/// +/// Handler for illusion temple reward request packets - sent by the client when the player clicks the +/// "Close" button on the result dialog after the event ended. +/// +/// +/// The packet belongs to the 0xBF group, which is dispatched by the . +/// Therefore this is a sub packet handler which is selected by the sub code, and not a handler of its own. +/// +[PlugIn] +[Guid("8B4E6C2A-9A3D-4E7F-8C1B-2D5A6F9E0B3C")] +[BelongsToGroup(MuHelperGroupHandler.GroupKey)] +internal class IllusionTempleRewardRequestHandlerPlugIn : ISubPacketHandlerPlugIn +{ + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => IllusionTempleRewardRequest.SubCode; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + if (packet.Length < IllusionTempleRewardRequest.Length + || player.CurrentMiniGame is not IllusionTempleContext illusionTemple) + { + return; + } + + await illusionTemple.ClaimRewardAsync(player).ConfigureAwait(false); + } +} diff --git a/src/GameServer/MessageHandler/MiniGames/IllusionTempleSkillRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/MiniGames/IllusionTempleSkillRequestHandlerPlugIn.cs new file mode 100644 index 0000000000..4ec086618a --- /dev/null +++ b/src/GameServer/MessageHandler/MiniGames/IllusionTempleSkillRequestHandlerPlugIn.cs @@ -0,0 +1,45 @@ +// +// 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.GameLogic; +using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.GameServer.MessageHandler.MuHelper; +using MUnique.OpenMU.Network.Packets.ClientToServer; +using MUnique.OpenMU.PlugIns; + +/// +/// Handler for illusion temple special skill request packets (210 to 213 - Order of Protection, +/// Restraint, Tracking and Weaken). +/// +/// +/// The packet belongs to the 0xBF group, which is dispatched by the . +/// Therefore this is a sub packet handler which is selected by the sub code, and not a handler of its own. +/// +[PlugIn] +[Guid("3E9B2F7D-6C1A-4E3D-9A2E-5D8B1C7F0A6E")] +[BelongsToGroup(MuHelperGroupHandler.GroupKey)] +internal class IllusionTempleSkillRequestHandlerPlugIn : ISubPacketHandlerPlugIn +{ + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => IllusionTempleSkillRequest.SubCode; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + if (packet.Length < IllusionTempleSkillRequest.Length + || player.CurrentMiniGame is not IllusionTempleContext illusionTemple) + { + return; + } + + IllusionTempleSkillRequest request = packet; + await illusionTemple.UseSkillAsync(player, request.SkillNumber, request.TargetObjectIndex).ConfigureAwait(false); + } +} diff --git a/src/GameServer/RemoteView/MiniGames/Extensions.cs b/src/GameServer/RemoteView/MiniGames/Extensions.cs index 9ce81ee8ab..1140e600db 100644 --- a/src/GameServer/RemoteView/MiniGames/Extensions.cs +++ b/src/GameServer/RemoteView/MiniGames/Extensions.cs @@ -70,4 +70,19 @@ public static ChaosCastleEnterResult.EnterResult ToChaosCastleEnterResult(this E _ => ChaosCastleEnterResult.EnterResult.Failed, }; } + + /// + /// Converts the to the result value of the . + /// + /// The enter result. + /// The converted result. + /// + /// Unlike the other mini games, the illusion temple result is an undocumented plain byte, so there is no + /// generated enum to map to. Only the success value (0) is known for sure, because it's consistent over + /// all other mini games; every failure is reported as 1 until the client's distinct failure codes are known. + /// + public static byte ToIllusionTempleEnterResult(this EnterResult enterResult) + { + return 0; + } } \ No newline at end of file diff --git a/src/GameServer/RemoteView/MiniGames/IllusionTempleEventStateViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/IllusionTempleEventStateViewPlugIn.cs new file mode 100644 index 0000000000..173d398157 --- /dev/null +++ b/src/GameServer/RemoteView/MiniGames/IllusionTempleEventStateViewPlugIn.cs @@ -0,0 +1,49 @@ +// +// 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.MiniGames; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of the which is forwarding everything to the game client with specific data packets. +/// +[PlugIn] +[Display(Name = PlugInName, Description = PlugInDescription)] +[Guid("0A68AF5F-2982-4748-BB82-AB493D80E8D2")] +public class IllusionTempleEventStateViewPlugIn : IIllusionTempleEventStateViewPlugIn +{ + private const string PlugInName = "Illusion Temple Event State"; + + private const string PlugInDescription = "View plugin which tells the client about the state of an illusion temple event, so that it opens the event interface and removes the barriers of the arena."; + + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public IllusionTempleEventStateViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask ChangeEventStateAsync(byte templeNumber, IllusionTempleEventStatus state) + { + await this._player.Connection.SendIllusionTempleEventStateAsync(templeNumber, Convert(state)).ConfigureAwait(false); + } + + private static IllusionTempleEventState.EventState Convert(IllusionTempleEventStatus state) + { + return state switch + { + IllusionTempleEventStatus.WaitingRoom => IllusionTempleEventState.EventState.WaitingRoom, + IllusionTempleEventStatus.Preparation => IllusionTempleEventState.EventState.Preparation, + IllusionTempleEventStatus.BattleStarted => IllusionTempleEventState.EventState.BattleStarted, + IllusionTempleEventStatus.Ended => IllusionTempleEventState.EventState.Ended, + _ => throw new ArgumentOutOfRangeException(nameof(state), state, null), + }; + } +} diff --git a/src/GameServer/RemoteView/MiniGames/IllusionTempleHolyItemRelicsViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/IllusionTempleHolyItemRelicsViewPlugIn.cs new file mode 100644 index 0000000000..b03ff49a5e --- /dev/null +++ b/src/GameServer/RemoteView/MiniGames/IllusionTempleHolyItemRelicsViewPlugIn.cs @@ -0,0 +1,56 @@ +// +// 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.MiniGames; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of the which is forwarding everything to the game client with specific data packets. +/// +[PlugIn] +[Display(Name = PlugInName, Description = PlugInDescription)] +[Guid("9C6C0B0B-6E6B-4B6E-9C3A-6A7EDAF07B1E")] +public class IllusionTempleHolyItemRelicsViewPlugIn : IIllusionTempleHolyItemRelicsViewPlugIn +{ + private const string PlugInName = "Illusion Temple Holy Item Relics"; + + private const string PlugInDescription = "View plugin which announces the player who just picked up the holy relic of a running illusion temple event."; + + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public IllusionTempleHolyItemRelicsViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask ShowHolyItemRelicsAsync(ushort playerId, string playerName) + { + if (this._player.Connection is not { } connection) + { + return; + } + + int Write() + { + var size = IllusionTempleHolyItemRelicsRef.Length; + var span = connection.Output.GetSpan(size)[..size]; + var message = new IllusionTempleHolyItemRelicsRef(span) + { + UserIndex = playerId, + Name = playerName, + }; + + return message.Header.Length; + } + + await connection.SendAsync(Write).ConfigureAwait(false); + } +} diff --git a/src/GameServer/RemoteView/MiniGames/IllusionTempleScoreTableViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/IllusionTempleScoreTableViewPlugIn.cs new file mode 100644 index 0000000000..fb6280e290 --- /dev/null +++ b/src/GameServer/RemoteView/MiniGames/IllusionTempleScoreTableViewPlugIn.cs @@ -0,0 +1,70 @@ +// +// 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; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of the which is forwarding everything to the game client with specific data packets. +/// +[PlugIn] +[Display(Name = PlugInName, Description = PlugInDescription)] +[Guid("F47F9EC1-0030-49AB-BCF2-986AA6AFA8C6")] +public class IllusionTempleScoreTableViewPlugIn : IIllusionTempleScoreTableViewPlugIn +{ + private const string PlugInName = "Illusion Temple Score Table"; + + private const string PlugInDescription = "View plugin which sends the result of a finished illusion temple event to the client, so that it can show the score board."; + + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public IllusionTempleScoreTableViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask ShowScoreTableAsync(byte alliedForcesPoints, byte illusionForcesPoints, IReadOnlyCollection<(string Name, byte MapNumber, IllusionTempleTeam Team, byte CharacterClass, int AddedExperience)> results) + { + if (this._player.Connection is not { } connection) + { + return; + } + + int Write() + { + var size = IllusionTempleResultRef.GetRequiredSize(results.Count); + var span = connection.Output.GetSpan(size)[..size]; + var message = new IllusionTempleResultRef(span) + { + Team1Points = alliedForcesPoints, + Team2Points = illusionForcesPoints, + PlayerCount = (byte)results.Count, + }; + + var i = 0; + foreach (var (name, mapNumber, team, characterClass, addedExperience) in results) + { + var entry = message[i]; + entry.Name = name; + entry.MapNumber = mapNumber; + entry.Team = (byte)team; + entry.Class = characterClass; + entry.AddedExperience = (uint)Math.Max(0, addedExperience); + i++; + } + + return size; + } + + await connection.SendAsync(Write).ConfigureAwait(false); + } +} diff --git a/src/GameServer/RemoteView/MiniGames/IllusionTempleSkillEndedViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/IllusionTempleSkillEndedViewPlugIn.cs new file mode 100644 index 0000000000..1bb22f011d --- /dev/null +++ b/src/GameServer/RemoteView/MiniGames/IllusionTempleSkillEndedViewPlugIn.cs @@ -0,0 +1,56 @@ +// +// 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.MiniGames; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of the which is forwarding everything to the game client with specific data packets. +/// +[PlugIn] +[Display(Name = PlugInName, Description = PlugInDescription)] +[Guid("7A1B2C3D-4E5F-4A6B-8C9D-0E1F2A3B4C5D")] +public class IllusionTempleSkillEndedViewPlugIn : IIllusionTempleSkillEndedViewPlugin +{ + private const string PlugInName = "Illusion Temple Skill Ended"; + + private const string PlugInDescription = "View plugin which announces that an illusion temple special skill's effect ended on an object."; + + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public IllusionTempleSkillEndedViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask ShowSkillEndedAsync(ushort skillNumber, ushort objectId) + { + if (this._player.Connection is not { } connection) + { + return; + } + + int Write() + { + var size = IllusionTempleSkillEndedRef.Length; + var span = connection.Output.GetSpan(size)[..size]; + var message = new IllusionTempleSkillEndedRef(span) + { + SkillNumber = skillNumber, + ObjectIndex = objectId, + }; + + return message.Header.Length; + } + + await connection.SendAsync(Write).ConfigureAwait(false); + } +} diff --git a/src/GameServer/RemoteView/MiniGames/IllusionTempleSkillPointUpdateViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/IllusionTempleSkillPointUpdateViewPlugIn.cs new file mode 100644 index 0000000000..d68435cd8a --- /dev/null +++ b/src/GameServer/RemoteView/MiniGames/IllusionTempleSkillPointUpdateViewPlugIn.cs @@ -0,0 +1,55 @@ +// +// 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.MiniGames; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of the which is forwarding everything to the game client with specific data packets. +/// +[PlugIn] +[Display(Name = PlugInName, Description = PlugInDescription)] +[Guid("6F6E7B84-8C3B-4B6F-9C0A-3B7C6E5C6D2A")] +public class IllusionTempleSkillPointUpdateViewPlugIn : IIllusionTempleSkillPointUpdateViewPlugin +{ + private const string PlugInName = "Illusion Temple Skill Point Update"; + + private const string PlugInDescription = "View plugin which updates the skill point balance of a player taking part in a running illusion temple event."; + + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public IllusionTempleSkillPointUpdateViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask UpdateSkillPointsAsync(byte skillPoints) + { + if (this._player.Connection is not { } connection) + { + return; + } + + int Write() + { + var size = IllusionTempleSkillPointUpdateRef.Length; + var span = connection.Output.GetSpan(size)[..size]; + var message = new IllusionTempleSkillPointUpdateRef(span) + { + SkillPoints = skillPoints, + }; + + return message.Header.Length; + } + + await connection.SendAsync(Write).ConfigureAwait(false); + } +} diff --git a/src/GameServer/RemoteView/MiniGames/IllusionTempleSkillUsageResultViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/IllusionTempleSkillUsageResultViewPlugIn.cs new file mode 100644 index 0000000000..7cd0e93082 --- /dev/null +++ b/src/GameServer/RemoteView/MiniGames/IllusionTempleSkillUsageResultViewPlugIn.cs @@ -0,0 +1,58 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.RemoteView.MiniGames; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of the which is forwarding everything to the game client with specific data packets. +/// +[PlugIn] +[Display(Name = PlugInName, Description = PlugInDescription)] +[Guid("2D6A6B0E-3F8B-4B1A-9A0D-8E7C6B5A4F31")] +public class IllusionTempleSkillUsageResultViewPlugIn : IIllusionTempleSkillUsageResultViewPlugin +{ + private const string PlugInName = "Illusion Temple Skill Usage Result"; + + private const string PlugInDescription = "View plugin which shows the result of a requested illusion temple special skill."; + + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public IllusionTempleSkillUsageResultViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask ShowSkillUsageResultAsync(bool success, ushort skillNumber, ushort sourceId, ushort targetId) + { + if (this._player.Connection is not { } connection) + { + return; + } + + int Write() + { + var size = IllusionTempleSkillUsageResultRef.Length; + var span = connection.Output.GetSpan(size)[..size]; + var message = new IllusionTempleSkillUsageResultRef(span) + { + Result = (byte)(success ? 1 : 0), + SkillNumber = skillNumber, + SourceObjectId = sourceId, + TargetObjectId = targetId, + }; + + return message.Header.Length; + } + + await connection.SendAsync(Write).ConfigureAwait(false); + } +} diff --git a/src/GameServer/RemoteView/MiniGames/IllusionTempleStateViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/IllusionTempleStateViewPlugIn.cs new file mode 100644 index 0000000000..e4b7310f28 --- /dev/null +++ b/src/GameServer/RemoteView/MiniGames/IllusionTempleStateViewPlugIn.cs @@ -0,0 +1,97 @@ +// +// 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; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of the which is forwarding everything to the game client with specific data packets. +/// +[PlugIn] +[Display(Name = PlugInName, Description = PlugInDescription)] +[Guid("0FD01C6D-AEAB-4F06-8CFC-AC0E89C7B526")] +public class IllusionTempleStateViewPlugIn : IIllusionTempleStateViewPlugin +{ + private const string PlugInName = "Illusion Temple State"; + + private const string PlugInDescription = "View plugin which sends the cyclic state update of a running illusion temple event to the client - the remaining time, the points of both teams and the positions of the own team."; + + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public IllusionTempleStateViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask UpdateStateAsync( + TimeSpan remainingTime, + byte alliedForcesPoints, + byte illusionForcesPoints, + IllusionTempleTeam ownTeam, + IReadOnlyCollection<(ushort PlayerId, byte MapNumber, byte PositionX, byte PositionY)> teamMembers, + (ushort PlayerId, byte PositionX, byte PositionY)? relicCarrier) + { + if (this._player.Connection is not { } connection) + { + return; + } + + var seconds = (ushort)Math.Clamp(remainingTime.TotalSeconds, 0, ushort.MaxValue); + + int Write() + { + // Unlike other list packets, this one has no fixed-length array with unused slots - only + // PartyCount entries are sent. + var size = IllusionTempleStateRef.GetRequiredSize(teamMembers.Count); + var span = connection.Output.GetSpan(size)[..size]; + var message = new IllusionTempleStateRef(span) + { + RemainingSeconds = seconds, + AlliedForcesPoints = alliedForcesPoints, + IllusionForcesPoints = illusionForcesPoints, + MyTeam = (byte)ownTeam, + PartyCount = (byte)teamMembers.Count, + }; + + // The holy relic's carrier is identified by his id and current position. As long as nobody + // carries it, both have to be filled with -1 resp. 0xFF: a real value makes the client + // announce a carrier, which it does with the score animation. + if (relicCarrier is { } carrier) + { + message.RelicCarrierId = carrier.PlayerId; + message.PositionX = carrier.PositionX; + message.PositionY = carrier.PositionY; + } + else + { + message.RelicCarrierId = 0xFFFF; + message.PositionX = 0xFF; + message.PositionY = 0xFF; + } + + var i = 0; + foreach (var (playerId, mapNumber, positionX, positionY) in teamMembers) + { + var entry = message[i]; + entry.PlayerId = playerId; + entry.MapNumber = mapNumber; + entry.PositionX = positionX; + entry.PositionY = positionY; + i++; + } + + return size; + } + + await connection.SendAsync(Write).ConfigureAwait(false); + } +} diff --git a/src/GameServer/RemoteView/MiniGames/IllusionTempleUserCountViewPlugin.cs b/src/GameServer/RemoteView/MiniGames/IllusionTempleUserCountViewPlugin.cs new file mode 100644 index 0000000000..962f123710 --- /dev/null +++ b/src/GameServer/RemoteView/MiniGames/IllusionTempleUserCountViewPlugin.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.RemoteView.MiniGames; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of the which is forwarding everything to the game client with specific data packets. +/// +[PlugIn] +[Display(Name = PlugInName, Description = PlugInDescription)] +[Guid("E7E73888-8B8E-4D06-8D95-1C1CDEDDA8CC")] +public class IllusionTempleUserCountViewPlugIn : IShowIllusionTempleUserCountViewPlugIn +{ + private const string PlugInName = "Illusion Temple User Count"; + + private const string PlugInDescription = "View plugin which sends the number of players of each illusion temple to the client, so that it can show them in the entrance dialog."; + + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public IllusionTempleUserCountViewPlugIn(RemotePlayer player) + { + this._player = player; + } + + /// + public async ValueTask ShowUserCountAsync(IReadOnlyList userCounts) + { + // The packet holds one byte per temple, so a missing or oversized count is reported as the + // closest value the client can display, instead of throwing or wrapping around. + byte Count(int index) => index < userCounts.Count + ? (byte)Math.Clamp(userCounts[index], 0, byte.MaxValue) + : (byte)0; + + await this._player.Connection.SendIllusionTempleUserCountAsync( + Count(0), + Count(1), + Count(2), + Count(3), + Count(4), + Count(5)).ConfigureAwait(false); + } +} diff --git a/src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs index 702a539090..6d1ffae589 100644 --- a/src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs +++ b/src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs @@ -5,6 +5,7 @@ namespace MUnique.OpenMU.GameServer.RemoteView.MiniGames; using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; using MUnique.OpenMU.GameLogic.MiniGames; using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames; using MUnique.OpenMU.Network.Packets.ServerToClient; @@ -41,6 +42,9 @@ public async ValueTask ShowResultAsync(MiniGameType miniGameType, EnterResult en case MiniGameType.ChaosCastle: await this._player.Connection.SendChaosCastleEnterResultAsync(enterResult.ToChaosCastleEnterResult()).ConfigureAwait(false); break; + case MiniGameType.IllusionTemple: + await this._player.Connection.SendIllusionTempleEnterResultAsync(enterResult.ToIllusionTempleEnterResult()).ConfigureAwait(false); + break; case MiniGameType.Undefined: throw new ArgumentException("undefined game type", nameof(miniGameType)); default: diff --git a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs index 94f2c8782e..b2e533b630 100644 --- a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs +++ b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs @@ -5472,6 +5472,36 @@ int WritePacket() await connection.SendAsync(WritePacket).ConfigureAwait(false); } + /// + /// Sends a to this connection. + /// + /// The connection. + /// The temple number. + /// The state. + /// + /// Is sent by the server when: The state of an illusion temple event changed, e.g. when the battle starts. + /// Causes reaction on client side: The client shows or hides the user interface of the event - the score board, the timer and the mini map - and applies the barriers of the arena, which are hardcoded at client side. + /// + public static async ValueTask SendIllusionTempleEventStateAsync(this IConnection? connection, byte @templeNumber, IllusionTempleEventState.EventState @state) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = IllusionTempleEventStateRef.Length; + var packet = new IllusionTempleEventStateRef(connection.Output.GetSpan(length)[..length]); + packet.TempleNumber = @templeNumber; + packet.State = @state; + + 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 136c06d1a1..4f2e855755 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs @@ -25631,7 +25631,7 @@ public byte Result /// /// Is sent by the server when: The player is in the illusion temple event and the server sends a cyclic update. -/// Causes reaction on client side: The client shows the state in the user interface. +/// Causes reaction on client side: The client shows the score board, the remaining time, and the carrier of the holy relic and the own team mates on its mini map. /// public readonly struct IllusionTempleState { @@ -25695,12 +25695,12 @@ public ushort RemainingSeconds } /// - /// Gets or sets the player index. + /// Gets or sets the relic carrier id. /// - public ushort PlayerIndex + public ushort RelicCarrierId { - get => ReadUInt16LittleEndian(this._data.Span[4..]); - set => WriteUInt16LittleEndian(this._data.Span[4..], value); + get => ReadUInt16LittleEndian(this._data.Span[6..]); + set => WriteUInt16LittleEndian(this._data.Span[6..], value); } /// @@ -25708,8 +25708,8 @@ public ushort PlayerIndex /// public byte PositionX { - get => this._data.Span[6]; - set => this._data.Span[6] = value; + get => this._data.Span[8]; + set => this._data.Span[8] = value; } /// @@ -25717,26 +25717,26 @@ public byte PositionX /// public byte PositionY { - get => this._data.Span[7]; - set => this._data.Span[7] = value; + get => this._data.Span[9]; + set => this._data.Span[9] = value; } /// - /// Gets or sets the team 1 points. + /// Gets or sets the allied forces points. /// - public byte Team1Points + public byte AlliedForcesPoints { - get => this._data.Span[8]; - set => this._data.Span[8] = value; + get => this._data.Span[10]; + set => this._data.Span[10] = value; } /// - /// Gets or sets the team 2 points. + /// Gets or sets the illusion forces points. /// - public byte Team2Points + public byte IllusionForcesPoints { - get => this._data.Span[9]; - set => this._data.Span[9] = value; + get => this._data.Span[11]; + set => this._data.Span[11] = value; } /// @@ -25744,8 +25744,8 @@ public byte Team2Points /// public byte MyTeam { - get => this._data.Span[10]; - set => this._data.Span[10] = value; + get => this._data.Span[12]; + set => this._data.Span[12] = value; } /// @@ -25753,14 +25753,14 @@ public byte MyTeam /// public byte PartyCount { - get => this._data.Span[11]; - set => this._data.Span[11] = value; + get => this._data.Span[13]; + set => this._data.Span[13] = value; } /// - /// Gets the of the specified index. + /// Gets the of the specified index. /// - public IllusionTemplePartyEntry this[int index] => new (this._data.Slice(12 + index * IllusionTemplePartyEntry.Length)); + public IllusionTempleTeamMate this[int index] => new (this._data.Slice(14 + index * IllusionTempleTeamMate.Length)); /// /// Performs an implicit conversion from a Memory of bytes to a . @@ -25777,25 +25777,25 @@ public byte PartyCount public static implicit operator Memory(IllusionTempleState packet) => packet._data; /// - /// Calculates the size of the packet for the specified count of . + /// Calculates the size of the packet for the specified count of . /// - /// The count of from which the size will be calculated. + /// The count of from which the size will be calculated. - public static int GetRequiredSize(int partyMembersCount) => partyMembersCount * IllusionTemplePartyEntry.Length + 12; + public static int GetRequiredSize(int teamMatesCount) => teamMatesCount * IllusionTempleTeamMate.Length + 14; /// -/// Contains the info about a party member in illusion temple.. +/// Contains the info about a team mate in the illusion temple, so that the client can show him on its mini map. Only PartyCount entries are sent - there are no unused/zeroed slots.. /// -public readonly struct IllusionTemplePartyEntry +public readonly struct IllusionTempleTeamMate { private readonly Memory _data; /// - /// Initializes a new instance of the struct. + /// Initializes a new instance of the struct. /// /// The underlying data. - public IllusionTemplePartyEntry(Memory data) + public IllusionTempleTeamMate(Memory data) { this._data = data; } @@ -25817,10 +25817,10 @@ public ushort PlayerId /// /// Gets or sets the map number. /// - public ushort MapNumber + public byte MapNumber { - get => ReadUInt16LittleEndian(this._data.Span[2..]); - set => WriteUInt16LittleEndian(this._data.Span[2..], value); + get => this._data.Span[2]; + set => this._data.Span[2] = value; } /// @@ -26174,7 +26174,7 @@ public byte PlayerCount /// /// Gets the of the specified index. /// - public PlayerResult this[int index] => new (this._data.Slice(10 + index * PlayerResult.Length)); + public PlayerResult this[int index] => new (this._data.Slice(7 + index * PlayerResult.Length)); /// /// Performs an implicit conversion from a Memory of bytes to a . @@ -26195,7 +26195,7 @@ public byte PlayerCount /// /// The count of from which the size will be calculated. - public static int GetRequiredSize(int playersCount) => playersCount * PlayerResult.Length + 10; + public static int GetRequiredSize(int playersCount) => playersCount * PlayerResult.Length + 7; /// @@ -26217,15 +26217,15 @@ public PlayerResult(Memory 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 => 17; + public static int Length => 20; /// /// Gets or sets the name. /// public string Name { - get => this._data.Span.ExtractString(0, this._data.Length - 0, System.Text.Encoding.UTF8); - set => this._data.Slice(0).Span.WriteString(value, System.Text.Encoding.UTF8); + get => this._data.Span.ExtractString(0, 10, System.Text.Encoding.UTF8); + set => this._data.Slice(0, 10).Span.WriteString(value, System.Text.Encoding.UTF8); } /// @@ -26260,21 +26260,9 @@ public byte Class /// public uint AddedExperience { - get => ReadUInt32LittleEndian(this._data.Span[13..]); - set => WriteUInt32LittleEndian(this._data.Span[13..], value); + get => ReadUInt32LittleEndian(this._data.Span[16..]); + set => WriteUInt32LittleEndian(this._data.Span[16..], value); } - - /// - /// Calculates the size of the packet for the specified field content. - /// - /// The content of the variable 'Name' field from which the size will be calculated. - public static int GetRequiredSize(string content) => System.Text.Encoding.UTF8.GetByteCount(content) + 1 + 0; - - /// - /// Calculates the size of the packet for the specified field content. - /// - /// The content length in bytes of the variable 'Name' field from which the size will be calculated. - public static int GetRequiredSize(int contentLength) => contentLength + 1 + 0; } } @@ -26567,6 +26555,127 @@ public string Name } +/// +/// Is sent by the server when: The state of an illusion temple event changed, e.g. when the battle starts. +/// Causes reaction on client side: The client shows or hides the user interface of the event - the score board, the timer and the mini map - and applies the barriers of the arena, which are hardcoded at client side. +/// +public readonly struct IllusionTempleEventState +{ + /// + /// Defines the state of an illusion temple event. + /// + public enum EventState + { + /// + /// The player entered the event and waits for it to start. It's only sent to the entering player, not to all participants. + /// + WaitingRoom = 0, + + /// + /// The preparation started: the players have been moved into the arena and assigned to their teams. The client opens the event interface with the score board, the timer and the mini map. + /// + Preparation = 1, + + /// + /// The battle started: the statues are up and the barriers of the arena are removed, so that the players can reach the cursed statue. + /// + BattleStarted = 2, + + /// + /// The battle ended - the client closes the event interface. + /// + Ended = 3, + } + + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public IllusionTempleEventState(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 IllusionTempleEventState(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 => 0x09; + + /// + /// 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 temple number. + /// + public byte TempleNumber + { + get => this._data.Span[4]; + set => this._data.Span[4] = value; + } + + /// + /// Gets or sets the state. + /// + public IllusionTempleEventState.EventState State + { + get => (EventState)this._data.Span[5]; + set => this._data.Span[5] = (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 IllusionTempleEventState(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(IllusionTempleEventState packet) => packet._data; +} + + /// /// Is sent by the server when: ? /// Causes reaction on client side: The client shows the skill points. diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml index 8b9116dd77..a782641dbe 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -9355,7 +9355,7 @@ IllusionTempleState ServerToClient The player is in the illusion temple event and the server sends a cyclic update. - The client shows the state in the user interface. + The client shows the score board, the remaining time, and the carrier of the holy relic and the own team mates on its mini map. 4 @@ -9363,51 +9363,51 @@ RemainingSeconds - 4 + 6 ShortLittleEndian - PlayerIndex + RelicCarrierId - 6 + 8 Byte PositionX - 7 + 9 Byte PositionY - 8 + 10 Byte - Team1Points + AlliedForcesPoints - 9 + 11 Byte - Team2Points + IllusionForcesPoints - 10 + 12 Byte MyTeam - 11 + 13 Byte PartyCount - 12 + 14 Structure[] - IllusionTemplePartyEntry - PartyMembers + IllusionTempleTeamMate + TeamMates - IllusionTemplePartyEntry - Contains the info about a party member in illusion temple. + IllusionTempleTeamMate + Contains the info about a team mate in the illusion temple, so that the client can show him on its mini map. Only PartyCount entries are sent - there are no unused/zeroed slots. 5 @@ -9417,7 +9417,7 @@ 2 - ShortLittleEndian + Byte MapNumber @@ -9533,7 +9533,7 @@ PlayerCount - 10 + 7 Structure[] PlayerResult Players @@ -9544,12 +9544,13 @@ PlayerResult Contains the result of a player in the event. - 17 + 20 0 String Name + 10 10 @@ -9566,8 +9567,12 @@ Byte Class + - 13 + 16 IntegerLittleEndian AddedExperience @@ -9636,6 +9641,57 @@ + + C1HeaderWithSubCode + BF + 09 + IllusionTempleEventState + 6 + ServerToClient + The state of an illusion temple event changed, e.g. when the battle starts. + The client shows or hides the user interface of the event - the score board, the timer and the mini map - and applies the barriers of the arena, which are hardcoded at client side. + + + 4 + Byte + TempleNumber + + + 5 + Enum + EventState + State + + + + + EventState + Defines the state of an illusion temple event. + + + WaitingRoom + The player entered the event and waits for it to start. It's only sent to the entering player, not to all participants. + 0 + + + Preparation + The preparation started: the players have been moved into the arena and assigned to their teams. The client opens the event interface with the score board, the timer and the mini map. + 1 + + + BattleStarted + The battle started: the statues are up and the barriers of the arena are removed, so that the players can reach the cursed statue. + 2 + + + Ended + The battle ended - the client closes the event interface. + 3 + + + + + C1HeaderWithSubCode BF diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs index 86f1cff55a..7834c262aa 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs @@ -24297,7 +24297,7 @@ public byte Result /// /// Is sent by the server when: The player is in the illusion temple event and the server sends a cyclic update. -/// Causes reaction on client side: The client shows the state in the user interface. +/// Causes reaction on client side: The client shows the score board, the remaining time, and the carrier of the holy relic and the own team mates on its mini map. /// public readonly ref struct IllusionTempleStateRef { @@ -24361,12 +24361,12 @@ public ushort RemainingSeconds } /// - /// Gets or sets the player index. + /// Gets or sets the relic carrier id. /// - public ushort PlayerIndex + public ushort RelicCarrierId { - get => ReadUInt16LittleEndian(this._data[4..]); - set => WriteUInt16LittleEndian(this._data[4..], value); + get => ReadUInt16LittleEndian(this._data[6..]); + set => WriteUInt16LittleEndian(this._data[6..], value); } /// @@ -24374,8 +24374,8 @@ public ushort PlayerIndex /// public byte PositionX { - get => this._data[6]; - set => this._data[6] = value; + get => this._data[8]; + set => this._data[8] = value; } /// @@ -24383,26 +24383,26 @@ public byte PositionX /// public byte PositionY { - get => this._data[7]; - set => this._data[7] = value; + get => this._data[9]; + set => this._data[9] = value; } /// - /// Gets or sets the team 1 points. + /// Gets or sets the allied forces points. /// - public byte Team1Points + public byte AlliedForcesPoints { - get => this._data[8]; - set => this._data[8] = value; + get => this._data[10]; + set => this._data[10] = value; } /// - /// Gets or sets the team 2 points. + /// Gets or sets the illusion forces points. /// - public byte Team2Points + public byte IllusionForcesPoints { - get => this._data[9]; - set => this._data[9] = value; + get => this._data[11]; + set => this._data[11] = value; } /// @@ -24410,8 +24410,8 @@ public byte Team2Points /// public byte MyTeam { - get => this._data[10]; - set => this._data[10] = value; + get => this._data[12]; + set => this._data[12] = value; } /// @@ -24419,14 +24419,14 @@ public byte MyTeam /// public byte PartyCount { - get => this._data[11]; - set => this._data[11] = value; + get => this._data[13]; + set => this._data[13] = value; } /// - /// Gets the of the specified index. + /// Gets the of the specified index. /// - public IllusionTemplePartyEntryRef this[int index] => new (this._data[(12 + index * IllusionTemplePartyEntryRef.Length)..]); + public IllusionTempleTeamMateRef this[int index] => new (this._data[(14 + index * IllusionTempleTeamMateRef.Length)..]); /// /// Performs an implicit conversion from a Span of bytes to a . @@ -24443,25 +24443,25 @@ public byte PartyCount public static implicit operator Span(IllusionTempleStateRef packet) => packet._data; /// - /// Calculates the size of the packet for the specified count of . + /// Calculates the size of the packet for the specified count of . /// - /// The count of from which the size will be calculated. + /// The count of from which the size will be calculated. - public static int GetRequiredSize(int partyMembersCount) => partyMembersCount * IllusionTemplePartyEntryRef.Length + 12; + public static int GetRequiredSize(int teamMatesCount) => teamMatesCount * IllusionTempleTeamMateRef.Length + 14; /// -/// Contains the info about a party member in illusion temple.. +/// Contains the info about a team mate in the illusion temple, so that the client can show him on its mini map. Only PartyCount entries are sent - there are no unused/zeroed slots.. /// -public readonly ref struct IllusionTemplePartyEntryRef +public readonly ref struct IllusionTempleTeamMateRef { private readonly Span _data; /// - /// Initializes a new instance of the struct. + /// Initializes a new instance of the struct. /// /// The underlying data. - public IllusionTemplePartyEntryRef(Span data) + public IllusionTempleTeamMateRef(Span data) { this._data = data; } @@ -24483,10 +24483,10 @@ public ushort PlayerId /// /// Gets or sets the map number. /// - public ushort MapNumber + public byte MapNumber { - get => ReadUInt16LittleEndian(this._data[2..]); - set => WriteUInt16LittleEndian(this._data[2..], value); + get => this._data[2]; + set => this._data[2] = value; } /// @@ -24840,7 +24840,7 @@ public byte PlayerCount /// /// Gets the of the specified index. /// - public PlayerResultRef this[int index] => new (this._data[(10 + index * PlayerResultRef.Length)..]); + public PlayerResultRef this[int index] => new (this._data[(7 + index * PlayerResultRef.Length)..]); /// /// Performs an implicit conversion from a Span of bytes to a . @@ -24861,7 +24861,7 @@ public byte PlayerCount /// /// The count of from which the size will be calculated. - public static int GetRequiredSize(int playersCount) => playersCount * PlayerResultRef.Length + 10; + public static int GetRequiredSize(int playersCount) => playersCount * PlayerResultRef.Length + 7; /// @@ -24883,15 +24883,15 @@ public PlayerResultRef(Span 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 => 17; + public static int Length => 20; /// /// Gets or sets the name. /// public string Name { - get => this._data.ExtractString(0, this._data.Length - 0, System.Text.Encoding.UTF8); - set => this._data.Slice(0).WriteString(value, System.Text.Encoding.UTF8); + get => this._data.ExtractString(0, 10, System.Text.Encoding.UTF8); + set => this._data.Slice(0, 10).WriteString(value, System.Text.Encoding.UTF8); } /// @@ -24926,21 +24926,9 @@ public byte Class /// public uint AddedExperience { - get => ReadUInt32LittleEndian(this._data[13..]); - set => WriteUInt32LittleEndian(this._data[13..], value); + get => ReadUInt32LittleEndian(this._data[16..]); + set => WriteUInt32LittleEndian(this._data[16..], value); } - - /// - /// Calculates the size of the packet for the specified field content. - /// - /// The content of the variable 'Name' field from which the size will be calculated. - public static int GetRequiredSize(string content) => System.Text.Encoding.UTF8.GetByteCount(content) + 1 + 0; - - /// - /// Calculates the size of the packet for the specified field content. - /// - /// The content length in bytes of the variable 'Name' field from which the size will be calculated. - public static int GetRequiredSize(int contentLength) => contentLength + 1 + 0; } } @@ -25233,6 +25221,101 @@ public string Name } +/// +/// Is sent by the server when: The state of an illusion temple event changed, e.g. when the battle starts. +/// Causes reaction on client side: The client shows or hides the user interface of the event - the score board, the timer and the mini map - and applies the barriers of the arena, which are hardcoded at client side. +/// +public readonly ref struct IllusionTempleEventStateRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public IllusionTempleEventStateRef(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 IllusionTempleEventStateRef(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 => 0x09; + + /// + /// 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 temple number. + /// + public byte TempleNumber + { + get => this._data[4]; + set => this._data[4] = value; + } + + /// + /// Gets or sets the state. + /// + public IllusionTempleEventState.EventState State + { + get => (IllusionTempleEventState.EventState)this._data[5]; + set => this._data[5] = (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 IllusionTempleEventStateRef(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(IllusionTempleEventStateRef packet) => packet._data; +} + + /// /// Is sent by the server when: ? /// Causes reaction on client side: The client shows the skill points. diff --git a/src/Persistence/EntityFramework/Migrations/20260819113052_AddMiniGameMinimumPlayerCount.Designer.cs b/src/Persistence/EntityFramework/Migrations/20260819113052_AddMiniGameMinimumPlayerCount.Designer.cs new file mode 100644 index 0000000000..27de65f627 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/20260819113052_AddMiniGameMinimumPlayerCount.Designer.cs @@ -0,0 +1,5296 @@ +// +using System; +using MUnique.OpenMU.Persistence.EntityFramework; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations +{ + [DbContext(typeof(EntityDataContext))] + [Migration("20260819113052_AddMiniGameMinimumPlayerCount")] + partial class AddMiniGameMinimumPlayerCount + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatBanUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("EMail") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsBot") + .HasColumnType("boolean"); + + b.Property("IsTemplate") + .HasColumnType("boolean"); + + b.Property("IsVaultExtended") + .HasColumnType("boolean"); + + b.Property("LanguageIsoCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("en"); + + b.Property("LoginName") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegistrationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("TimeZone") + .HasColumnType("smallint"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.Property("VaultPassword") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LoginName") + .IsUnique(); + + b.HasIndex("VaultId") + .IsUnique(); + + b.ToTable("Account", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AccountCharacterClass", b => + { + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.HasKey("AccountId", "CharacterClassId"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("AccountCharacterClass", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("FullAncientSetEquipped") + .HasColumnType("boolean"); + + b.Property("Pose") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("AppearanceData", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AreaSkillSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DelayBetweenHits") + .HasColumnType("interval"); + + b.Property("DelayPerOneDistance") + .HasColumnType("interval"); + + b.Property("EffectRange") + .HasColumnType("integer"); + + b.Property("FrustumDistance") + .HasColumnType("real"); + + b.Property("FrustumEndWidth") + .HasColumnType("real"); + + b.Property("FrustumStartWidth") + .HasColumnType("real"); + + b.Property("HitChancePerDistanceMultiplier") + .HasColumnType("real"); + + b.Property("MaximumNumberOfHitsPerAttack") + .HasColumnType("integer"); + + b.Property("MaximumNumberOfHitsPerTarget") + .HasColumnType("integer"); + + b.Property("MinimumNumberOfHitsPerAttack") + .HasColumnType("integer"); + + b.Property("MinimumNumberOfHitsPerTarget") + .HasColumnType("integer"); + + b.Property("ProjectileCount") + .HasColumnType("integer"); + + b.Property("TargetAreaDiameter") + .HasColumnType("real"); + + b.Property("UseDeferredHits") + .HasColumnType("boolean"); + + b.Property("UseFrustumFilter") + .HasColumnType("boolean"); + + b.Property("UseTargetAreaFilter") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("AreaSkillSettings", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Designation") + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("MaximumValue") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("AttributeDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateType") + .HasColumnType("integer"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("InputAttributeId") + .HasColumnType("uuid"); + + b.Property("InputOperand") + .HasColumnType("real"); + + b.Property("InputOperator") + .HasColumnType("integer"); + + b.Property("OperandAttributeId") + .HasColumnType("uuid"); + + b.Property("PowerUpDefinitionValueId") + .HasColumnType("uuid"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.Property("TargetAttributeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CharacterClassId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("InputAttributeId"); + + b.HasIndex("OperandAttributeId"); + + b.HasIndex("PowerUpDefinitionValueId"); + + b.HasIndex("SkillId"); + + b.HasIndex("TargetAttributeId"); + + b.ToTable("AttributeRelationship", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRequirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeId") + .HasColumnType("uuid"); + + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("MinimumValue") + .HasColumnType("integer"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.Property("SkillId1") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AttributeId"); + + b.HasIndex("GameMapDefinitionId"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("SkillId"); + + b.HasIndex("SkillId1"); + + b.ToTable("AttributeRequirement", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroundId") + .HasColumnType("uuid"); + + b.Property("LeftGoalId") + .HasColumnType("uuid"); + + b.Property("LeftTeamSpawnPointX") + .HasColumnType("smallint"); + + b.Property("LeftTeamSpawnPointY") + .HasColumnType("smallint"); + + b.Property("RightGoalId") + .HasColumnType("uuid"); + + b.Property("RightTeamSpawnPointX") + .HasColumnType("smallint"); + + b.Property("RightTeamSpawnPointY") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroundId") + .IsUnique(); + + b.HasIndex("LeftGoalId") + .IsUnique(); + + b.HasIndex("RightGoalId") + .IsUnique(); + + b.ToTable("BattleZoneDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Buff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MagicEffectDefinitionId") + .HasColumnType("uuid"); + + b.Property("MaximumLevel") + .HasColumnType("integer"); + + b.Property("MinimumLevel") + .HasColumnType("integer"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MagicEffectDefinitionId") + .IsUnique(); + + b.HasIndex("MonsterDefinitionId"); + + b.ToTable("Buff", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("CharacterSlot") + .HasColumnType("smallint"); + + b.Property("CharacterStatus") + .HasColumnType("integer"); + + b.Property("CreateDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMapId") + .HasColumnType("uuid"); + + b.Property("Experience") + .HasColumnType("bigint"); + + b.Property("InventoryExtensions") + .HasColumnType("integer"); + + b.Property("InventoryId") + .HasColumnType("uuid"); + + b.Property("IsStoreOpened") + .HasColumnType("boolean"); + + b.Property("KeyConfiguration") + .HasColumnType("bytea"); + + b.Property("LevelUpPoints") + .HasColumnType("integer"); + + b.Property("MasterExperience") + .HasColumnType("bigint"); + + b.Property("MasterLevelUpPoints") + .HasColumnType("integer"); + + b.Property("MuHelperConfiguration") + .HasColumnType("bytea"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("PlayerKillCount") + .HasColumnType("integer"); + + b.Property("Pose") + .HasColumnType("smallint"); + + b.Property("PositionX") + .HasColumnType("smallint"); + + b.Property("PositionY") + .HasColumnType("smallint"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("StateRemainingSeconds") + .HasColumnType("integer"); + + b.Property("StoreName") + .HasColumnType("text"); + + b.Property("UsedFruitPoints") + .HasColumnType("integer"); + + b.Property("UsedNegFruitPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CharacterClassId"); + + b.HasIndex("CurrentMapId"); + + b.HasIndex("InventoryId") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Character", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanGetCreated") + .HasColumnType("boolean"); + + b.Property("ComboDefinitionId") + .HasColumnType("uuid"); + + b.Property("CreationAllowedFlag") + .HasColumnType("smallint"); + + b.Property("FruitCalculation") + .HasColumnType("integer"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("HomeMapId") + .HasColumnType("uuid"); + + b.Property("IsMasterClass") + .HasColumnType("boolean"); + + b.Property("LevelRequirementByCreation") + .HasColumnType("smallint"); + + b.Property("LevelWarpRequirementReductionPercent") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NextGenerationClassId") + .HasColumnType("uuid"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ComboDefinitionId") + .IsUnique(); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("HomeMapId"); + + b.HasIndex("NextGenerationClassId"); + + b.ToTable("CharacterClass", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterDropItemGroup", b => + { + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.HasKey("CharacterId", "DropItemGroupId"); + + b.HasIndex("DropItemGroupId"); + + b.ToTable("CharacterDropItemGroup", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActiveQuestId") + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("ClientActionPerformed") + .HasColumnType("boolean"); + + b.Property("Group") + .HasColumnType("smallint"); + + b.Property("LastFinishedQuestId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActiveQuestId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("LastFinishedQuestId"); + + b.ToTable("CharacterQuestState", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientCleanUpInterval") + .HasColumnType("interval"); + + b.Property("ClientTimeout") + .HasColumnType("interval"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaximumConnections") + .HasColumnType("integer"); + + b.Property("RoomCleanUpInterval") + .HasColumnType("interval"); + + b.Property("ServerId") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.ToTable("ChatServerDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerEndpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatServerDefinitionId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("NetworkPort") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChatServerDefinitionId"); + + b.HasIndex("ClientId"); + + b.ToTable("ChatServerEndpoint", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CombinationBonusRequirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ItemOptionCombinationBonusId") + .HasColumnType("uuid"); + + b.Property("MinimumCount") + .HasColumnType("integer"); + + b.Property("OptionTypeId") + .HasColumnType("uuid"); + + b.Property("SubOptionType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemOptionCombinationBonusId"); + + b.HasIndex("OptionTypeId"); + + b.ToTable("CombinationBonusRequirement", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConfigurationUpdate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ConfigurationUpdate", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConfigurationUpdateState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CurrentInstalledVersion") + .HasColumnType("integer"); + + b.Property("InitializationKey") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ConfigurationUpdateState", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConnectServerDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CheckMaxConnectionsPerAddress") + .HasColumnType("boolean"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientListenerPort") + .HasColumnType("integer"); + + b.Property("CurrentPatchVersion") + .HasColumnType("bytea"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DisconnectOnUnknownPacket") + .HasColumnType("boolean"); + + b.Property("ListenerBacklog") + .HasColumnType("integer"); + + b.Property("MaxConnections") + .HasColumnType("integer"); + + b.Property("MaxConnectionsPerAddress") + .HasColumnType("integer"); + + b.Property("MaxFtpRequests") + .HasColumnType("integer"); + + b.Property("MaxIpRequests") + .HasColumnType("integer"); + + b.Property("MaxServerListRequests") + .HasColumnType("integer"); + + b.Property("MaximumReceiveSize") + .HasColumnType("smallint"); + + b.Property("PatchAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServerId") + .HasColumnType("smallint"); + + b.Property("Timeout") + .HasColumnType("interval"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ConnectServerDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConstValueAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("DefinitionId") + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("CharacterClassId"); + + b.HasIndex("DefinitionId"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ConstValueAttribute", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Chance") + .HasColumnType("double precision"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("ItemLevel") + .HasColumnType("smallint"); + + b.Property("ItemType") + .HasColumnType("integer"); + + b.Property("MaximumMonsterLevel") + .HasColumnType("smallint"); + + b.Property("MinimumMonsterLevel") + .HasColumnType("smallint"); + + b.Property("MonsterId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("MonsterId"); + + b.ToTable("DropItemGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroupItemDefinition", b => + { + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("DropItemGroupId", "ItemDefinitionId"); + + b.HasIndex("ItemDefinitionId"); + + b.ToTable("DropItemGroupItemDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DuelConfigurationId") + .HasColumnType("uuid"); + + b.Property("FirstPlayerGateId") + .HasColumnType("uuid"); + + b.Property("Index") + .HasColumnType("smallint"); + + b.Property("SecondPlayerGateId") + .HasColumnType("uuid"); + + b.Property("SpectatorsGateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DuelConfigurationId"); + + b.HasIndex("FirstPlayerGateId"); + + b.HasIndex("SecondPlayerGateId"); + + b.HasIndex("SpectatorsGateId"); + + b.ToTable("DuelArea", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntranceFee") + .HasColumnType("integer"); + + b.Property("ExitId") + .HasColumnType("uuid"); + + b.Property("MaximumScore") + .HasColumnType("integer"); + + b.Property("MaximumSpectatorsPerDuelRoom") + .HasColumnType("integer"); + + b.Property("MinimumCharacterLevel") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExitId"); + + b.ToTable("DuelConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.EnterGate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("LevelRequirement") + .HasColumnType("smallint"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("TargetGateId") + .HasColumnType("uuid"); + + b.Property("X1") + .HasColumnType("smallint"); + + b.Property("X2") + .HasColumnType("smallint"); + + b.Property("Y1") + .HasColumnType("smallint"); + + b.Property("Y2") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GameMapDefinitionId"); + + b.HasIndex("TargetGateId"); + + b.ToTable("EnterGate", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Direction") + .HasColumnType("integer"); + + b.Property("IsSpawnGate") + .HasColumnType("boolean"); + + b.Property("MapId") + .HasColumnType("uuid"); + + b.Property("X1") + .HasColumnType("smallint"); + + b.Property("X2") + .HasColumnType("smallint"); + + b.Property("Y1") + .HasColumnType("smallint"); + + b.Property("Y2") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("MapId"); + + b.ToTable("ExitGate", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Friend", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Accepted") + .HasColumnType("boolean"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("FriendId") + .HasColumnType("uuid"); + + b.Property("RequestOpen") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasAlternateKey("CharacterId", "FriendId"); + + b.ToTable("Friend", "friend"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameClientDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Episode") + .HasColumnType("smallint"); + + b.Property("Language") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("smallint"); + + b.Property("Serial") + .HasColumnType("bytea"); + + b.Property("Version") + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.ToTable("GameClientDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AreaSkillHitsPlayer") + .HasColumnType("boolean"); + + b.Property("CharacterNameRegex") + .HasColumnType("text"); + + b.Property("ClampMoneyOnPickup") + .HasColumnType("boolean"); + + b.Property("DamagePerOneItemDurability") + .HasColumnType("double precision"); + + b.Property("DamagePerOnePetDurability") + .HasColumnType("double precision"); + + b.Property("DuelConfigurationId") + .HasColumnType("uuid"); + + b.Property("ExcellentItemDropLevelDelta") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)25); + + b.Property("ExperienceFormula") + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("if(level == 0, 0, if(level < 256, 10 * (level + 8) * (level - 1) * (level - 1), (10 * (level + 8) * (level - 1) * (level - 1)) + (1000 * (level - 247) * (level - 256) * (level - 256))))"); + + b.Property("ExperienceRate") + .HasColumnType("real"); + + b.Property("HitsPerOneItemDurability") + .HasColumnType("double precision"); + + b.Property("InfoRange") + .HasColumnType("smallint"); + + b.Property("ItemDropDuration") + .ValueGeneratedOnAdd() + .HasColumnType("interval") + .HasDefaultValue(new TimeSpan(0, 0, 1, 0, 0)); + + b.Property("LetterSendPrice") + .HasColumnType("integer"); + + b.Property("MasterExperienceFormula") + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("(505 * level * level * level) + (35278500 * level) + (228045 * level * level)"); + + b.Property("MasterExperienceRate") + .HasColumnType("real"); + + b.Property("MaximumCharactersPerAccount") + .HasColumnType("smallint"); + + b.Property("MaximumInventoryMoney") + .HasColumnType("integer"); + + b.Property("MaximumItemOptionLevelDrop") + .HasColumnType("smallint"); + + b.Property("MaximumLetters") + .HasColumnType("integer"); + + b.Property("MaximumLevel") + .HasColumnType("smallint"); + + b.Property("MaximumMasterLevel") + .HasColumnType("smallint"); + + b.Property("MaximumPartySize") + .HasColumnType("smallint"); + + b.Property("MaximumPasswordLength") + .HasColumnType("integer"); + + b.Property("MaximumVaultMoney") + .HasColumnType("integer"); + + b.Property("MinimumMonsterLevelForMasterExperience") + .HasColumnType("smallint"); + + b.Property("PreventExperienceOverflow") + .HasColumnType("boolean"); + + b.Property("RecoveryInterval") + .HasColumnType("integer"); + + b.Property("ShouldDropMoney") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("DuelConfigurationId") + .IsUnique(); + + b.ToTable("GameConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BattleZoneId") + .HasColumnType("uuid"); + + b.Property("Discriminator") + .HasColumnType("integer"); + + b.Property("ExpMultiplier") + .HasColumnType("double precision"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("SafezoneMapId") + .HasColumnType("uuid"); + + b.Property("TerrainData") + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("BattleZoneId") + .IsUnique(); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("SafezoneMapId"); + + b.ToTable("GameMapDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinitionDropItemGroup", b => + { + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.HasKey("GameMapDefinitionId", "DropItemGroupId"); + + b.HasIndex("DropItemGroupId"); + + b.ToTable("GameMapDefinitionDropItemGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MaximumPlayers") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.ToTable("GameServerConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfigurationGameMapDefinition", b => + { + b.Property("GameServerConfigurationId") + .HasColumnType("uuid"); + + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("GameServerConfigurationId", "GameMapDefinitionId"); + + b.HasIndex("GameMapDefinitionId"); + + b.ToTable("GameServerConfigurationGameMapDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExperienceRate") + .HasColumnType("real"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("PvpEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ServerConfigurationId") + .HasColumnType("uuid"); + + b.Property("ServerID") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("ServerConfigurationId"); + + b.ToTable("GameServerDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerEndpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AlternativePublishedPort") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("GameServerDefinitionId") + .HasColumnType("uuid"); + + b.Property("NetworkPort") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("GameServerDefinitionId"); + + b.ToTable("GameServerEndpoint", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllianceGuildId") + .HasColumnType("uuid"); + + b.Property("HostilityId") + .HasColumnType("uuid"); + + b.Property("Logo") + .HasColumnType("bytea"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Notice") + .HasColumnType("text"); + + b.Property("Score") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AllianceGuildId"); + + b.HasIndex("HostilityId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Guild", "guild"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GuildMember", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GuildId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.ToTable("GuildMember", "guild"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ItemOptionDefinitionId") + .HasColumnType("uuid"); + + b.Property("LevelType") + .HasColumnType("integer"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("OptionTypeId") + .HasColumnType("uuid"); + + b.Property("PowerUpDefinitionId") + .HasColumnType("uuid"); + + b.Property("SubOptionType") + .HasColumnType("integer"); + + b.Property("Weight") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ItemOptionDefinitionId"); + + b.HasIndex("OptionTypeId"); + + b.HasIndex("PowerUpDefinitionId") + .IsUnique(); + + b.ToTable("IncreasableItemOption", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefinitionId") + .HasColumnType("uuid"); + + b.Property("Durability") + .HasColumnType("double precision"); + + b.Property("HasSkill") + .HasColumnType("boolean"); + + b.Property("ItemSlot") + .HasColumnType("smallint"); + + b.Property("ItemStorageId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.Property("PetExperience") + .HasColumnType("integer"); + + b.Property("SocketCount") + .HasColumnType("integer"); + + b.Property("StorePrice") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId"); + + b.HasIndex("ItemStorageId"); + + b.ToTable("Item", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppearanceDataId") + .HasColumnType("uuid"); + + b.Property("DefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemSlot") + .HasColumnType("smallint"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("AppearanceDataId"); + + b.HasIndex("DefinitionId"); + + b.ToTable("ItemAppearance", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearanceItemOptionType", b => + { + b.Property("ItemAppearanceId") + .HasColumnType("uuid"); + + b.Property("ItemOptionTypeId") + .HasColumnType("uuid"); + + b.HasKey("ItemAppearanceId", "ItemOptionTypeId"); + + b.HasIndex("ItemOptionTypeId"); + + b.ToTable("ItemAppearanceItemOptionType", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemBasePowerUpDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateType") + .HasColumnType("integer"); + + b.Property("BaseValue") + .HasColumnType("real"); + + b.Property("BonusPerLevelTableId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("TargetAttributeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BonusPerLevelTableId"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("TargetAttributeId"); + + b.ToTable("ItemBasePowerUpDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCrafting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ItemCraftingHandlerClassName") + .IsRequired() + .HasColumnType("text"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("SimpleCraftingSettingsId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MonsterDefinitionId"); + + b.HasIndex("SimpleCraftingSettingsId") + .IsUnique(); + + b.ToTable("ItemCrafting", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddPercentage") + .HasColumnType("smallint"); + + b.Property("FailResult") + .HasColumnType("integer"); + + b.Property("MaximumAmount") + .HasColumnType("smallint"); + + b.Property("MaximumItemLevel") + .HasColumnType("smallint"); + + b.Property("MinimumAmount") + .HasColumnType("smallint"); + + b.Property("MinimumItemLevel") + .HasColumnType("smallint"); + + b.Property("NpcPriceDivisor") + .HasColumnType("integer"); + + b.Property("Reference") + .HasColumnType("smallint"); + + b.Property("SimpleCraftingSettingsId") + .HasColumnType("uuid"); + + b.Property("SuccessResult") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SimpleCraftingSettingsId"); + + b.ToTable("ItemCraftingRequiredItem", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItemItemDefinition", b => + { + b.Property("ItemCraftingRequiredItemId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("ItemCraftingRequiredItemId", "ItemDefinitionId"); + + b.HasIndex("ItemDefinitionId"); + + b.ToTable("ItemCraftingRequiredItemItemDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItemItemOptionType", b => + { + b.Property("ItemCraftingRequiredItemId") + .HasColumnType("uuid"); + + b.Property("ItemOptionTypeId") + .HasColumnType("uuid"); + + b.HasKey("ItemCraftingRequiredItemId", "ItemOptionTypeId"); + + b.HasIndex("ItemOptionTypeId"); + + b.ToTable("ItemCraftingRequiredItemItemOptionType", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingResultItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddLevel") + .HasColumnType("smallint"); + + b.Property("Durability") + .HasColumnType("smallint"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("RandomMaximumLevel") + .HasColumnType("smallint"); + + b.Property("RandomMinimumLevel") + .HasColumnType("smallint"); + + b.Property("Reference") + .HasColumnType("smallint"); + + b.Property("SimpleCraftingSettingsId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("SimpleCraftingSettingsId"); + + b.ToTable("ItemCraftingResultItem", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumeEffectId") + .HasColumnType("uuid"); + + b.Property("DropLevel") + .HasColumnType("smallint"); + + b.Property("DropsFromMonsters") + .HasColumnType("boolean"); + + b.Property("Durability") + .HasColumnType("smallint"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Group") + .HasColumnType("smallint"); + + b.Property("Height") + .HasColumnType("smallint"); + + b.Property("IsAmmunition") + .HasColumnType("boolean"); + + b.Property("IsBoundToCharacter") + .HasColumnType("boolean"); + + b.Property("IsQuestItem") + .HasColumnType("boolean"); + + b.Property("ItemSlotId") + .HasColumnType("uuid"); + + b.Property("MaximumDropLevel") + .HasColumnType("smallint"); + + b.Property("MaximumItemLevel") + .HasColumnType("smallint"); + + b.Property("MaximumSockets") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("PetExperienceFormula") + .HasColumnType("text"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.Property("StorageLimitPerCharacter") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("integer"); + + b.Property("Width") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ConsumeEffectId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("ItemSlotId"); + + b.HasIndex("SkillId"); + + b.ToTable("ItemDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionCharacterClass", b => + { + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.HasKey("ItemDefinitionId", "CharacterClassId"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("ItemDefinitionCharacterClass", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionItemOptionDefinition", b => + { + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemOptionDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("ItemDefinitionId", "ItemOptionDefinitionId"); + + b.HasIndex("ItemOptionDefinitionId"); + + b.ToTable("ItemDefinitionItemOptionDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionItemSetGroup", b => + { + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemSetGroupId") + .HasColumnType("uuid"); + + b.HasKey("ItemDefinitionId", "ItemSetGroupId"); + + b.HasIndex("ItemSetGroupId"); + + b.ToTable("ItemDefinitionItemSetGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Chance") + .HasColumnType("double precision"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DropEffect") + .HasColumnType("integer"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemLevel") + .HasColumnType("smallint"); + + b.Property("ItemType") + .HasColumnType("integer"); + + b.Property("MaximumLevel") + .HasColumnType("smallint"); + + b.Property("MaximumMonsterLevel") + .HasColumnType("smallint"); + + b.Property("MinimumLevel") + .HasColumnType("smallint"); + + b.Property("MinimumMonsterLevel") + .HasColumnType("smallint"); + + b.Property("MoneyAmount") + .HasColumnType("integer"); + + b.Property("MonsterId") + .HasColumnType("uuid"); + + b.Property("RequiredCharacterLevel") + .HasColumnType("smallint"); + + b.Property("SourceItemLevel") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("MonsterId"); + + b.ToTable("ItemDropItemGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroupItemDefinition", b => + { + b.Property("ItemDropItemGroupId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("ItemDropItemGroupId", "ItemDefinitionId"); + + b.HasIndex("ItemDefinitionId"); + + b.ToTable("ItemDropItemGroupItemDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemItemOfItemSet", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemOfItemSetId") + .HasColumnType("uuid"); + + b.HasKey("ItemId", "ItemOfItemSetId"); + + b.HasIndex("ItemOfItemSetId"); + + b.ToTable("ItemItemOfItemSet", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemLevelBonusTable", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOfItemSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AncientSetDiscriminator") + .HasColumnType("integer"); + + b.Property("BonusOptionId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemSetGroupId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BonusOptionId"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("ItemSetGroupId"); + + b.ToTable("ItemOfItemSet", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("OptionTypeId") + .HasColumnType("uuid"); + + b.Property("PowerUpDefinitionId") + .HasColumnType("uuid"); + + b.Property("SubOptionType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OptionTypeId"); + + b.HasIndex("PowerUpDefinitionId") + .IsUnique(); + + b.ToTable("ItemOption", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliesMultipleTimes") + .HasColumnType("boolean"); + + b.Property("BonusId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Number") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BonusId") + .IsUnique(); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemOptionCombinationBonus", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddChance") + .HasColumnType("real"); + + b.Property("AddsRandomly") + .HasColumnType("boolean"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("MaximumOptionsPerItem") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemOptionDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemOptionId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.HasIndex("ItemOptionId"); + + b.ToTable("ItemOptionLink", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionOfLevel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IncreasableItemOptionId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("PowerUpDefinitionId") + .HasColumnType("uuid"); + + b.Property("RequiredItemLevel") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IncreasableItemOptionId"); + + b.HasIndex("PowerUpDefinitionId") + .IsUnique(); + + b.ToTable("ItemOptionOfLevel", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemOptionType", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AlwaysApplies") + .HasColumnType("boolean"); + + b.Property("CountDistinct") + .HasColumnType("boolean"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("MinimumItemCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OptionsId") + .HasColumnType("uuid"); + + b.Property("SetLevel") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("OptionsId"); + + b.ToTable("ItemSetGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSlotType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("RawItemSlots") + .HasColumnType("text") + .HasColumnName("ItemSlots") + .HasJsonPropertyName("itemSlots"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemSlotType", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Money") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ItemStorage", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.JewelMix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("MixedJewelId") + .HasColumnType("uuid"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("SingleJewelId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("MixedJewelId"); + + b.HasIndex("SingleJewelId"); + + b.ToTable("JewelMix", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterBody", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Animation") + .HasColumnType("smallint"); + + b.Property("HeaderId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rotation") + .HasColumnType("smallint"); + + b.Property("SenderAppearanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HeaderId"); + + b.HasIndex("SenderAppearanceId") + .IsUnique(); + + b.ToTable("LetterBody", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterHeader", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("LetterDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadFlag") + .HasColumnType("boolean"); + + b.Property("ReceiverId") + .HasColumnType("uuid"); + + b.Property("SenderName") + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ReceiverId"); + + b.ToTable("LetterHeader", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LevelBonus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalValue") + .HasColumnType("real"); + + b.Property("ItemLevelBonusTableId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemLevelBonusTableId"); + + b.ToTable("LevelBonus", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChanceId") + .HasColumnType("uuid"); + + b.Property("ChancePvpId") + .HasColumnType("uuid"); + + b.Property("DurationDependsOnTargetLevel") + .HasColumnType("boolean"); + + b.Property("DurationId") + .HasColumnType("uuid"); + + b.Property("DurationPvpId") + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("InformObservers") + .HasColumnType("boolean"); + + b.Property("MonsterTargetLevelDivisor") + .HasColumnType("real"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("PlayerTargetLevelDivisor") + .HasColumnType("real"); + + b.Property("SendDuration") + .HasColumnType("boolean"); + + b.Property("StopByDeath") + .HasColumnType("boolean"); + + b.Property("SubType") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ChanceId") + .IsUnique(); + + b.HasIndex("ChancePvpId") + .IsUnique(); + + b.HasIndex("DurationId") + .IsUnique(); + + b.HasIndex("DurationPvpId") + .IsUnique(); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("MagicEffectDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Aggregation") + .HasColumnType("integer"); + + b.Property("DisplayValueFormula") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtendsDuration") + .HasColumnType("boolean"); + + b.Property("MaximumLevel") + .HasColumnType("smallint"); + + b.Property("MinimumLevel") + .HasColumnType("smallint"); + + b.Property("Rank") + .HasColumnType("smallint"); + + b.Property("ReplacedSkillId") + .HasColumnType("uuid"); + + b.Property("RootId") + .HasColumnType("uuid"); + + b.Property("TargetAttributeId") + .HasColumnType("uuid"); + + b.Property("ValueFormula") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedSkillId"); + + b.HasIndex("RootId"); + + b.HasIndex("TargetAttributeId"); + + b.ToTable("MasterSkillDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinitionSkill", b => + { + b.Property("MasterSkillDefinitionId") + .HasColumnType("uuid"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.HasKey("MasterSkillDefinitionId", "SkillId"); + + b.HasIndex("SkillId"); + + b.ToTable("MasterSkillDefinitionSkill", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillRoot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("MasterSkillRoot", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("MiniGameDefinitionId") + .HasColumnType("uuid"); + + b.Property("MinimumTargetLevel") + .HasColumnType("smallint"); + + b.Property("MultiplyKillsByPlayers") + .HasColumnType("boolean"); + + b.Property("NumberOfKills") + .HasColumnType("smallint"); + + b.Property("SpawnAreaId") + .HasColumnType("uuid"); + + b.Property("Target") + .HasColumnType("integer"); + + b.Property("TargetDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MiniGameDefinitionId"); + + b.HasIndex("SpawnAreaId") + .IsUnique(); + + b.HasIndex("TargetDefinitionId"); + + b.ToTable("MiniGameChangeEvent", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowParty") + .HasColumnType("boolean"); + + b.Property("ArePlayerKillersAllowedToEnter") + .HasColumnType("boolean"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnterDuration") + .HasColumnType("interval"); + + b.Property("EntranceFee") + .HasColumnType("integer"); + + b.Property("EntranceId") + .HasColumnType("uuid"); + + b.Property("ExitDuration") + .HasColumnType("interval"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("GameDuration") + .HasColumnType("interval"); + + b.Property("GameLevel") + .HasColumnType("smallint"); + + b.Property("MapCreationPolicy") + .HasColumnType("integer"); + + b.Property("MaximumCharacterLevel") + .HasColumnType("integer"); + + b.Property("MaximumPlayerCount") + .HasColumnType("integer"); + + b.Property("MaximumSpecialCharacterLevel") + .HasColumnType("integer"); + + b.Property("MinimumCharacterLevel") + .HasColumnType("integer"); + + b.Property("MinimumPlayerCount") + .HasColumnType("integer"); + + b.Property("MinimumSpecialCharacterLevel") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequiresMasterClass") + .HasColumnType("boolean"); + + b.Property("SaveRankingStatistics") + .HasColumnType("boolean"); + + b.Property("TicketItemId") + .HasColumnType("uuid"); + + b.Property("TicketItemLevel") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntranceId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("TicketItemId"); + + b.ToTable("MiniGameDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameRankingEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("GameInstanceId") + .HasColumnType("uuid"); + + b.Property("MiniGameId") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("MiniGameId"); + + b.ToTable("MiniGameRankingEntry", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ItemRewardId") + .HasColumnType("uuid"); + + b.Property("MiniGameDefinitionId") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("RequiredKillId") + .HasColumnType("uuid"); + + b.Property("RequiredSuccess") + .HasColumnType("integer"); + + b.Property("RewardAmount") + .HasColumnType("integer"); + + b.Property("RewardType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemRewardId"); + + b.HasIndex("MiniGameDefinitionId"); + + b.HasIndex("RequiredKillId"); + + b.ToTable("MiniGameReward", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameSpawnWave", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndTime") + .HasColumnType("interval"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("MiniGameDefinitionId") + .HasColumnType("uuid"); + + b.Property("StartTime") + .HasColumnType("interval"); + + b.Property("WaveNumber") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("MiniGameDefinitionId"); + + b.ToTable("MiniGameSpawnWave", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameTerrainChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EndX") + .HasColumnType("smallint"); + + b.Property("EndY") + .HasColumnType("smallint"); + + b.Property("IsClientUpdateRequired") + .HasColumnType("boolean"); + + b.Property("MiniGameChangeEventId") + .HasColumnType("uuid"); + + b.Property("SetTerrainAttribute") + .HasColumnType("boolean"); + + b.Property("StartX") + .HasColumnType("smallint"); + + b.Property("StartY") + .HasColumnType("smallint"); + + b.Property("TerrainAttribute") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MiniGameChangeEventId"); + + b.ToTable("MiniGameTerrainChange", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeDefinitionId") + .HasColumnType("uuid"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("AttributeDefinitionId"); + + b.HasIndex("MonsterDefinitionId"); + + b.ToTable("MonsterAttribute", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttackDelay") + .HasColumnType("interval"); + + b.Property("AttackRange") + .HasColumnType("smallint"); + + b.Property("AttackSkillId") + .HasColumnType("uuid"); + + b.Property("Attribute") + .HasColumnType("smallint"); + + b.Property("Designation") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("IntelligenceTypeName") + .HasColumnType("text"); + + b.Property("MerchantStoreId") + .HasColumnType("uuid"); + + b.Property("MoveDelay") + .HasColumnType("interval"); + + b.Property("MoveRange") + .HasColumnType("smallint"); + + b.Property("NpcWindow") + .HasColumnType("integer"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("NumberOfMaximumItemDrops") + .HasColumnType("integer"); + + b.Property("ObjectKind") + .HasColumnType("integer"); + + b.Property("RespawnDelay") + .HasColumnType("interval"); + + b.Property("ViewRange") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("AttackSkillId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("MerchantStoreId") + .IsUnique(); + + b.ToTable("MonsterDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinitionDropItemGroup", b => + { + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.HasKey("MonsterDefinitionId", "DropItemGroupId"); + + b.HasIndex("DropItemGroupId"); + + b.ToTable("MonsterDefinitionDropItemGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterSpawnArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Direction") + .HasColumnType("integer"); + + b.Property("GameMapId") + .HasColumnType("uuid"); + + b.Property("MaximumHealthOverride") + .HasColumnType("integer"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("smallint"); + + b.Property("SpawnTrigger") + .HasColumnType("integer"); + + b.Property("WaveNumber") + .HasColumnType("smallint"); + + b.Property("X1") + .HasColumnType("smallint"); + + b.Property("X2") + .HasColumnType("smallint"); + + b.Property("Y1") + .HasColumnType("smallint"); + + b.Property("Y2") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GameMapId"); + + b.HasIndex("MonsterDefinitionId"); + + b.ToTable("MonsterSpawnArea", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PlugInConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CustomConfiguration") + .HasColumnType("text"); + + b.Property("CustomPlugInSource") + .HasColumnType("text"); + + b.Property("ExternalAssemblyName") + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("TypeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("PlugInConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BoostId") + .HasColumnType("uuid"); + + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("MagicEffectDefinitionId") + .HasColumnType("uuid"); + + b.Property("MagicEffectDefinitionId1") + .HasColumnType("uuid"); + + b.Property("TargetAttributeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BoostId") + .IsUnique(); + + b.HasIndex("GameMapDefinitionId"); + + b.HasIndex("MagicEffectDefinitionId"); + + b.HasIndex("MagicEffectDefinitionId1"); + + b.HasIndex("TargetAttributeId"); + + b.ToTable("PowerUpDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateType") + .HasColumnType("integer"); + + b.Property("MaximumValue") + .HasColumnType("real"); + + b.Property("Value") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.ToTable("PowerUpDefinitionValue", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Group") + .HasColumnType("smallint"); + + b.Property("MaximumCharacterLevel") + .HasColumnType("integer"); + + b.Property("MinimumCharacterLevel") + .HasColumnType("integer"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("QualifiedCharacterId") + .HasColumnType("uuid"); + + b.Property("QuestGiverId") + .HasColumnType("uuid"); + + b.Property("RefuseNumber") + .HasColumnType("smallint"); + + b.Property("Repeatable") + .HasColumnType("boolean"); + + b.Property("RequiredStartMoney") + .HasColumnType("integer"); + + b.Property("RequiresClientAction") + .HasColumnType("boolean"); + + b.Property("StartingNumber") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("MonsterDefinitionId"); + + b.HasIndex("QualifiedCharacterId"); + + b.HasIndex("QuestGiverId"); + + b.ToTable("QuestDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestItemRequirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("MinimumNumber") + .HasColumnType("integer"); + + b.Property("QuestDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DropItemGroupId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuestDefinitionId"); + + b.ToTable("QuestItemRequirement", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MinimumNumber") + .HasColumnType("integer"); + + b.Property("MonsterId") + .HasColumnType("uuid"); + + b.Property("QuestDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MonsterId"); + + b.HasIndex("QuestDefinitionId"); + + b.ToTable("QuestMonsterKillRequirement", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirementState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterQuestStateId") + .HasColumnType("uuid"); + + b.Property("KillCount") + .HasColumnType("integer"); + + b.Property("RequirementId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CharacterQuestStateId"); + + b.HasIndex("RequirementId"); + + b.ToTable("QuestMonsterKillRequirementState", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeRewardId") + .HasColumnType("uuid"); + + b.Property("ItemRewardId") + .HasColumnType("uuid"); + + b.Property("QuestDefinitionId") + .HasColumnType("uuid"); + + b.Property("RewardType") + .HasColumnType("integer"); + + b.Property("SkillRewardId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AttributeRewardId"); + + b.HasIndex("ItemRewardId") + .IsUnique(); + + b.HasIndex("QuestDefinitionId"); + + b.HasIndex("SkillRewardId"); + + b.ToTable("QuestReward", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Rectangle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("X1") + .HasColumnType("smallint"); + + b.Property("X2") + .HasColumnType("smallint"); + + b.Property("Y1") + .HasColumnType("smallint"); + + b.Property("Y2") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.ToTable("Rectangle", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MaximumSuccessPercent") + .HasColumnType("smallint"); + + b.Property("Money") + .HasColumnType("integer"); + + b.Property("MoneyPerFinalSuccessPercentage") + .HasColumnType("integer"); + + b.Property("MultipleAllowed") + .HasColumnType("boolean"); + + b.Property("NpcPriceDivisor") + .HasColumnType("integer"); + + b.Property("ResultItemExcellentOptionChance") + .HasColumnType("smallint"); + + b.Property("ResultItemLuckOptionChance") + .HasColumnType("smallint"); + + b.Property("ResultItemMaxExcOptionCount") + .HasColumnType("smallint"); + + b.Property("ResultItemSelect") + .HasColumnType("integer"); + + b.Property("ResultItemSkillChance") + .HasColumnType("smallint"); + + b.Property("SuccessPercent") + .HasColumnType("smallint"); + + b.Property("SuccessPercentageAdditionForAncientItem") + .HasColumnType("integer"); + + b.Property("SuccessPercentageAdditionForExcellentItem") + .HasColumnType("integer"); + + b.Property("SuccessPercentageAdditionForGuardianItem") + .HasColumnType("integer"); + + b.Property("SuccessPercentageAdditionForLuck") + .HasColumnType("integer"); + + b.Property("SuccessPercentageAdditionForSocketItem") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("SimpleCraftingSettings", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AreaSkillSettingsId") + .HasColumnType("uuid"); + + b.Property("AttackDamage") + .HasColumnType("integer"); + + b.Property("DamageType") + .HasColumnType("integer"); + + b.Property("ElementalModifierTargetId") + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("ImplicitTargetRange") + .HasColumnType("smallint"); + + b.Property("MagicEffectDefId") + .HasColumnType("uuid"); + + b.Property("MasterDefinitionId") + .HasColumnType("uuid"); + + b.Property("MovesTarget") + .HasColumnType("boolean"); + + b.Property("MovesToTarget") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("NumberOfHitsPerAttack") + .HasColumnType("smallint"); + + b.Property("Range") + .HasColumnType("smallint"); + + b.Property("SkillType") + .HasColumnType("integer"); + + b.Property("SkipElementalModifier") + .HasColumnType("boolean"); + + b.Property("Target") + .HasColumnType("integer"); + + b.Property("TargetRestriction") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AreaSkillSettingsId") + .IsUnique(); + + b.HasIndex("ElementalModifierTargetId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("MagicEffectDefId"); + + b.HasIndex("MasterDefinitionId") + .IsUnique(); + + b.ToTable("Skill", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillCharacterClass", b => + { + b.Property("SkillId") + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.HasKey("SkillId", "CharacterClassId"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("SkillCharacterClass", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MaximumCompletionTime") + .HasColumnType("interval"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("SkillComboDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsFinalStep") + .HasColumnType("boolean"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("SkillComboDefinitionId") + .HasColumnType("uuid"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SkillComboDefinitionId"); + + b.HasIndex("SkillId"); + + b.ToTable("SkillComboStep", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("SkillId"); + + b.ToTable("SkillEntry", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.StatAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("DefinitionId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("DefinitionId"); + + b.ToTable("StatAttribute", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.StatAttributeDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeId") + .HasColumnType("uuid"); + + b.Property("BaseValue") + .HasColumnType("real"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("IncreasableByPlayer") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("AttributeId"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("StatAttributeDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SystemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AutoStart") + .HasColumnType("boolean"); + + b.Property("AutoUpdateSchema") + .HasColumnType("boolean"); + + b.Property("IpResolver") + .HasColumnType("integer"); + + b.Property("IpResolverParameter") + .HasColumnType("text"); + + b.Property("ReadConsoleInput") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("SystemConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.WarpInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Costs") + .HasColumnType("integer"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("GateId") + .HasColumnType("uuid"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("LevelRequirement") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("GateId"); + + b.ToTable("WarpInfo", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", "RawVault") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", "VaultId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawVault"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AccountCharacterClass", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", "Account") + .WithMany("JoinedUnlockedCharacterClasses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "CharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("CharacterClass"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "RawCharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId"); + + b.Navigation("RawCharacterClass"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawAttributes") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRelationship", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", null) + .WithMany("RawAttributeCombinations") + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawGlobalAttributeCombinations") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawInputAttribute") + .WithMany() + .HasForeignKey("InputAttributeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawOperandAttribute") + .WithMany() + .HasForeignKey("OperandAttributeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", null) + .WithMany("RawRelatedValues") + .HasForeignKey("PowerUpDefinitionValueId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", null) + .WithMany("RawAttributeRelationships") + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawTargetAttribute") + .WithMany() + .HasForeignKey("TargetAttributeId"); + + b.Navigation("RawInputAttribute"); + + b.Navigation("RawOperandAttribute"); + + b.Navigation("RawTargetAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRequirement", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawAttribute") + .WithMany() + .HasForeignKey("AttributeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", null) + .WithMany("RawMapRequirements") + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", null) + .WithMany("RawRequirements") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", null) + .WithMany("RawConsumeRequirements") + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", null) + .WithMany("RawRequirements") + .HasForeignKey("SkillId1") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Rectangle", "RawGround") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", "GroundId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Rectangle", "RawLeftGoal") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", "LeftGoalId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Rectangle", "RawRightGoal") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", "RightGoalId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawGround"); + + b.Navigation("RawLeftGoal"); + + b.Navigation("RawRightGoal"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Buff", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "RawMagicEffectDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Buff", "MagicEffectDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", null) + .WithMany("RawBuffs") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawMagicEffectDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", null) + .WithMany("RawCharacters") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "RawCharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawCurrentMap") + .WithMany() + .HasForeignKey("CurrentMapId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", "RawInventory") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "InventoryId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawCharacterClass"); + + b.Navigation("RawCurrentMap"); + + b.Navigation("RawInventory"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboDefinition", "RawComboDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "ComboDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawCharacterClasses") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawHomeMap") + .WithMany() + .HasForeignKey("HomeMapId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "RawNextGenerationClass") + .WithMany() + .HasForeignKey("NextGenerationClassId"); + + b.Navigation("RawComboDefinition"); + + b.Navigation("RawHomeMap"); + + b.Navigation("RawNextGenerationClass"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterDropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "Character") + .WithMany("JoinedDropItemGroups") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "DropItemGroup") + .WithMany() + .HasForeignKey("DropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Character"); + + b.Navigation("DropItemGroup"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", "RawActiveQuest") + .WithMany() + .HasForeignKey("ActiveQuestId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", null) + .WithMany("RawQuestStates") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", "RawLastFinishedQuest") + .WithMany() + .HasForeignKey("LastFinishedQuestId"); + + b.Navigation("RawActiveQuest"); + + b.Navigation("RawLastFinishedQuest"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerEndpoint", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerDefinition", null) + .WithMany("RawEndpoints") + .HasForeignKey("ChatServerDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameClientDefinition", "RawClient") + .WithMany() + .HasForeignKey("ClientId"); + + b.Navigation("RawClient"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CombinationBonusRequirement", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", null) + .WithMany("RawRequirements") + .HasForeignKey("ItemOptionCombinationBonusId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "RawOptionType") + .WithMany() + .HasForeignKey("OptionTypeId"); + + b.Navigation("RawOptionType"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConnectServerDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameClientDefinition", "RawClient") + .WithMany() + .HasForeignKey("ClientId"); + + b.Navigation("RawClient"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConstValueAttribute", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "CharacterClass") + .WithMany("RawBaseAttributeValues") + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawDefinition") + .WithMany() + .HasForeignKey("DefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "GameConfiguration") + .WithMany("RawGlobalBaseAttributeValues") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("CharacterClass"); + + b.Navigation("GameConfiguration"); + + b.Navigation("RawDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawDropItemGroups") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonster") + .WithMany() + .HasForeignKey("MonsterId"); + + b.Navigation("RawMonster"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroupItemDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "DropItemGroup") + .WithMany("JoinedPossibleItems") + .HasForeignKey("DropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DropItemGroup"); + + b.Navigation("ItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelArea", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", null) + .WithMany("RawDuelAreas") + .HasForeignKey("DuelConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawFirstPlayerGate") + .WithMany() + .HasForeignKey("FirstPlayerGateId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawSecondPlayerGate") + .WithMany() + .HasForeignKey("SecondPlayerGateId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawSpectatorsGate") + .WithMany() + .HasForeignKey("SpectatorsGateId"); + + b.Navigation("RawFirstPlayerGate"); + + b.Navigation("RawSecondPlayerGate"); + + b.Navigation("RawSpectatorsGate"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawExit") + .WithMany() + .HasForeignKey("ExitId"); + + b.Navigation("RawExit"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.EnterGate", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", null) + .WithMany("RawEnterGates") + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawTargetGate") + .WithMany() + .HasForeignKey("TargetGateId"); + + b.Navigation("RawTargetGate"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawMap") + .WithMany("RawExitGates") + .HasForeignKey("MapId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawMap"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", "RawDuelConfiguration") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "DuelConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawDuelConfiguration"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", "RawBattleZone") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "BattleZoneId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMaps") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawSafezoneMap") + .WithMany() + .HasForeignKey("SafezoneMapId"); + + b.Navigation("RawBattleZone"); + + b.Navigation("RawSafezoneMap"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinitionDropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "DropItemGroup") + .WithMany() + .HasForeignKey("DropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "GameMapDefinition") + .WithMany("JoinedDropItemGroups") + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DropItemGroup"); + + b.Navigation("GameMapDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfigurationGameMapDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "GameMapDefinition") + .WithMany() + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfiguration", "GameServerConfiguration") + .WithMany("JoinedMaps") + .HasForeignKey("GameServerConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GameMapDefinition"); + + b.Navigation("GameServerConfiguration"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "RawGameConfiguration") + .WithMany() + .HasForeignKey("GameConfigurationId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfiguration", "RawServerConfiguration") + .WithMany() + .HasForeignKey("ServerConfigurationId"); + + b.Navigation("RawGameConfiguration"); + + b.Navigation("RawServerConfiguration"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerEndpoint", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameClientDefinition", "RawClient") + .WithMany() + .HasForeignKey("ClientId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerDefinition", null) + .WithMany("RawEndpoints") + .HasForeignKey("GameServerDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawClient"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", "RawAllianceGuild") + .WithMany() + .HasForeignKey("AllianceGuildId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", "RawHostility") + .WithMany() + .HasForeignKey("HostilityId"); + + b.Navigation("RawAllianceGuild"); + + b.Navigation("RawHostility"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GuildMember", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", null) + .WithMany("RawMembers") + .HasForeignKey("GuildId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "Character") + .WithMany() + .HasForeignKey("Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", null) + .WithMany("RawPossibleOptions") + .HasForeignKey("ItemOptionDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "RawOptionType") + .WithMany() + .HasForeignKey("OptionTypeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "RawPowerUpDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", "PowerUpDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawOptionType"); + + b.Navigation("RawPowerUpDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawDefinition") + .WithMany() + .HasForeignKey("DefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", "RawItemStorage") + .WithMany("RawItems") + .HasForeignKey("ItemStorageId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawDefinition"); + + b.Navigation("RawItemStorage"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearance", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", null) + .WithMany("RawEquippedItems") + .HasForeignKey("AppearanceDataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawDefinition") + .WithMany() + .HasForeignKey("DefinitionId"); + + b.Navigation("RawDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearanceItemOptionType", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearance", "ItemAppearance") + .WithMany("JoinedVisibleOptions") + .HasForeignKey("ItemAppearanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "ItemOptionType") + .WithMany() + .HasForeignKey("ItemOptionTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemAppearance"); + + b.Navigation("ItemOptionType"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemBasePowerUpDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", "RawBonusPerLevelTable") + .WithMany() + .HasForeignKey("BonusPerLevelTableId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", null) + .WithMany("RawBasePowerUpAttributes") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawTargetAttribute") + .WithMany() + .HasForeignKey("TargetAttributeId"); + + b.Navigation("RawBonusPerLevelTable"); + + b.Navigation("RawTargetAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCrafting", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", null) + .WithMany("RawItemCraftings") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", "RawSimpleCraftingSettings") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCrafting", "SimpleCraftingSettingsId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawSimpleCraftingSettings"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", null) + .WithMany("RawRequiredItems") + .HasForeignKey("SimpleCraftingSettingsId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItemItemDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", "ItemCraftingRequiredItem") + .WithMany("JoinedPossibleItems") + .HasForeignKey("ItemCraftingRequiredItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemCraftingRequiredItem"); + + b.Navigation("ItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItemItemOptionType", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", "ItemCraftingRequiredItem") + .WithMany("JoinedRequiredItemOptions") + .HasForeignKey("ItemCraftingRequiredItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "ItemOptionType") + .WithMany() + .HasForeignKey("ItemOptionTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemCraftingRequiredItem"); + + b.Navigation("ItemOptionType"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingResultItem", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", null) + .WithMany("RawResultItems") + .HasForeignKey("SimpleCraftingSettingsId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "RawConsumeEffect") + .WithMany() + .HasForeignKey("ConsumeEffectId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItems") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSlotType", "RawItemSlot") + .WithMany() + .HasForeignKey("ItemSlotId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawSkill") + .WithMany() + .HasForeignKey("SkillId"); + + b.Navigation("RawConsumeEffect"); + + b.Navigation("RawItemSlot"); + + b.Navigation("RawSkill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionCharacterClass", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "CharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany("JoinedQualifiedCharacters") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CharacterClass"); + + b.Navigation("ItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionItemOptionDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany("JoinedPossibleItemOptions") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", "ItemOptionDefinition") + .WithMany() + .HasForeignKey("ItemOptionDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemDefinition"); + + b.Navigation("ItemOptionDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionItemSetGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany("JoinedPossibleItemSetGroups") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", "ItemSetGroup") + .WithMany() + .HasForeignKey("ItemSetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemDefinition"); + + b.Navigation("ItemSetGroup"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", null) + .WithMany("RawDropItems") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonster") + .WithMany() + .HasForeignKey("MonsterId"); + + b.Navigation("RawMonster"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroupItemDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroup", "ItemDropItemGroup") + .WithMany("JoinedPossibleItems") + .HasForeignKey("ItemDropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemDefinition"); + + b.Navigation("ItemDropItemGroup"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemItemOfItemSet", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", "Item") + .WithMany("JoinedItemSetGroups") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOfItemSet", "ItemOfItemSet") + .WithMany() + .HasForeignKey("ItemOfItemSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemOfItemSet"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemLevelBonusTables") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOfItemSet", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", "RawBonusOption") + .WithMany() + .HasForeignKey("BonusOptionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", "RawItemSetGroup") + .WithMany("RawItems") + .HasForeignKey("ItemSetGroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawBonusOption"); + + b.Navigation("RawItemDefinition"); + + b.Navigation("RawItemSetGroup"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOption", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "RawOptionType") + .WithMany() + .HasForeignKey("OptionTypeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "RawPowerUpDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOption", "PowerUpDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawOptionType"); + + b.Navigation("RawPowerUpDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "RawBonus") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", "BonusId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemOptionCombinationBonuses") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawBonus"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemOptions") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionLink", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", null) + .WithMany("RawItemOptions") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", "RawItemOption") + .WithMany() + .HasForeignKey("ItemOptionId"); + + b.Navigation("RawItemOption"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionOfLevel", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", null) + .WithMany("RawLevelDependentOptions") + .HasForeignKey("IncreasableItemOptionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "RawPowerUpDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionOfLevel", "PowerUpDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawPowerUpDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemOptionTypes") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemSetGroups") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", "RawOptions") + .WithMany() + .HasForeignKey("OptionsId"); + + b.Navigation("RawOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSlotType", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemSlotTypes") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.JewelMix", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawJewelMixes") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawMixedJewel") + .WithMany() + .HasForeignKey("MixedJewelId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawSingleJewel") + .WithMany() + .HasForeignKey("SingleJewelId"); + + b.Navigation("RawMixedJewel"); + + b.Navigation("RawSingleJewel"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterBody", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterHeader", "RawHeader") + .WithMany() + .HasForeignKey("HeaderId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", "RawSenderAppearance") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterBody", "SenderAppearanceId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawHeader"); + + b.Navigation("RawSenderAppearance"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterHeader", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "Receiver") + .WithMany("RawLetters") + .HasForeignKey("ReceiverId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Receiver"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LevelBonus", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", null) + .WithMany("RawBonusPerLevel") + .HasForeignKey("ItemLevelBonusTableId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawChance") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "ChanceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawChancePvp") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "ChancePvpId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawDuration") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "DurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawDurationPvp") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "DurationPvpId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMagicEffects") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawChance"); + + b.Navigation("RawChancePvp"); + + b.Navigation("RawDuration"); + + b.Navigation("RawDurationPvp"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawReplacedSkill") + .WithMany() + .HasForeignKey("ReplacedSkillId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillRoot", "RawRoot") + .WithMany() + .HasForeignKey("RootId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawTargetAttribute") + .WithMany() + .HasForeignKey("TargetAttributeId"); + + b.Navigation("RawReplacedSkill"); + + b.Navigation("RawRoot"); + + b.Navigation("RawTargetAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinitionSkill", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", "MasterSkillDefinition") + .WithMany("JoinedRequiredMasterSkills") + .HasForeignKey("MasterSkillDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "Skill") + .WithMany() + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MasterSkillDefinition"); + + b.Navigation("Skill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillRoot", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMasterSkillRoots") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", null) + .WithMany("RawChangeEvents") + .HasForeignKey("MiniGameDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterSpawnArea", "RawSpawnArea") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", "SpawnAreaId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawTargetDefinition") + .WithMany() + .HasForeignKey("TargetDefinitionId"); + + b.Navigation("RawSpawnArea"); + + b.Navigation("RawTargetDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawEntrance") + .WithMany() + .HasForeignKey("EntranceId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMiniGameDefinitions") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawTicketItem") + .WithMany() + .HasForeignKey("TicketItemId"); + + b.Navigation("RawEntrance"); + + b.Navigation("RawTicketItem"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameRankingEntry", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "RawCharacter") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", "RawMiniGame") + .WithMany() + .HasForeignKey("MiniGameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawCharacter"); + + b.Navigation("RawMiniGame"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameReward", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "RawItemReward") + .WithMany() + .HasForeignKey("ItemRewardId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", null) + .WithMany("RawRewards") + .HasForeignKey("MiniGameDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawRequiredKill") + .WithMany() + .HasForeignKey("RequiredKillId"); + + b.Navigation("RawItemReward"); + + b.Navigation("RawRequiredKill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameSpawnWave", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", null) + .WithMany("RawSpawnWaves") + .HasForeignKey("MiniGameDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameTerrainChange", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", null) + .WithMany("RawTerrainChanges") + .HasForeignKey("MiniGameChangeEventId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterAttribute", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawAttributeDefinition") + .WithMany() + .HasForeignKey("AttributeDefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", null) + .WithMany("RawAttributes") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAttributeDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawAttackSkill") + .WithMany() + .HasForeignKey("AttackSkillId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMonsters") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", "RawMerchantStore") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "MerchantStoreId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAttackSkill"); + + b.Navigation("RawMerchantStore"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinitionDropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "DropItemGroup") + .WithMany() + .HasForeignKey("DropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "MonsterDefinition") + .WithMany("JoinedDropItemGroups") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DropItemGroup"); + + b.Navigation("MonsterDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterSpawnArea", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawGameMap") + .WithMany("RawMonsterSpawns") + .HasForeignKey("GameMapId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonsterDefinition") + .WithMany() + .HasForeignKey("MonsterDefinitionId"); + + b.Navigation("RawGameMap"); + + b.Navigation("RawMonsterDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PlugInConfiguration", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawPlugInConfigurations") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawBoost") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "BoostId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", null) + .WithMany("RawCharacterPowerUpDefinitions") + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", null) + .WithMany("RawPowerUpDefinitions") + .HasForeignKey("MagicEffectDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", null) + .WithMany("RawPowerUpDefinitionsPvp") + .HasForeignKey("MagicEffectDefinitionId1") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_PowerUpDefinition_MagicEffectDefinition_MagicEffectDefinit~1"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawTargetAttribute") + .WithMany() + .HasForeignKey("TargetAttributeId"); + + b.Navigation("RawBoost"); + + b.Navigation("RawTargetAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", null) + .WithMany("RawQuests") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "RawQualifiedCharacter") + .WithMany() + .HasForeignKey("QualifiedCharacterId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawQuestGiver") + .WithMany() + .HasForeignKey("QuestGiverId"); + + b.Navigation("RawQualifiedCharacter"); + + b.Navigation("RawQuestGiver"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestItemRequirement", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "RawDropItemGroup") + .WithMany() + .HasForeignKey("DropItemGroupId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawItem") + .WithMany() + .HasForeignKey("ItemId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", null) + .WithMany("RawRequiredItems") + .HasForeignKey("QuestDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawDropItemGroup"); + + b.Navigation("RawItem"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirement", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonster") + .WithMany() + .HasForeignKey("MonsterId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", null) + .WithMany("RawRequiredMonsterKills") + .HasForeignKey("QuestDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawMonster"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirementState", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", null) + .WithMany("RawRequirementStates") + .HasForeignKey("CharacterQuestStateId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirement", "RawRequirement") + .WithMany() + .HasForeignKey("RequirementId"); + + b.Navigation("RawRequirement"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestReward", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawAttributeReward") + .WithMany() + .HasForeignKey("AttributeRewardId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", "RawItemReward") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestReward", "ItemRewardId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", null) + .WithMany("RawRewards") + .HasForeignKey("QuestDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawSkillReward") + .WithMany() + .HasForeignKey("SkillRewardId"); + + b.Navigation("RawAttributeReward"); + + b.Navigation("RawItemReward"); + + b.Navigation("RawSkillReward"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AreaSkillSettings", "RawAreaSkillSettings") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "AreaSkillSettingsId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawElementalModifierTarget") + .WithMany() + .HasForeignKey("ElementalModifierTargetId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawSkills") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "RawMagicEffectDef") + .WithMany() + .HasForeignKey("MagicEffectDefId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", "RawMasterDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "MasterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAreaSkillSettings"); + + b.Navigation("RawElementalModifierTarget"); + + b.Navigation("RawMagicEffectDef"); + + b.Navigation("RawMasterDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillCharacterClass", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "CharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "Skill") + .WithMany("JoinedQualifiedCharacters") + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CharacterClass"); + + b.Navigation("Skill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboStep", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboDefinition", null) + .WithMany("RawSteps") + .HasForeignKey("SkillComboDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawSkill") + .WithMany() + .HasForeignKey("SkillId"); + + b.Navigation("RawSkill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillEntry", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", null) + .WithMany("RawLearnedSkills") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawSkill") + .WithMany() + .HasForeignKey("SkillId"); + + b.Navigation("RawSkill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.StatAttribute", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", null) + .WithMany("RawAttributes") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", null) + .WithMany("RawAttributes") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawDefinition") + .WithMany() + .HasForeignKey("DefinitionId"); + + b.Navigation("RawDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.StatAttributeDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawAttribute") + .WithMany() + .HasForeignKey("AttributeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", null) + .WithMany("RawStatAttributes") + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.WarpInfo", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawWarpList") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawGate") + .WithMany() + .HasForeignKey("GateId"); + + b.Navigation("RawGate"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", b => + { + b.Navigation("JoinedUnlockedCharacterClasses"); + + b.Navigation("RawAttributes"); + + b.Navigation("RawCharacters"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", b => + { + b.Navigation("RawEquippedItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b => + { + b.Navigation("JoinedDropItemGroups"); + + b.Navigation("RawAttributes"); + + b.Navigation("RawLearnedSkills"); + + b.Navigation("RawLetters"); + + b.Navigation("RawQuestStates"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", b => + { + b.Navigation("RawAttributeCombinations"); + + b.Navigation("RawBaseAttributeValues"); + + b.Navigation("RawStatAttributes"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", b => + { + b.Navigation("RawRequirementStates"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerDefinition", b => + { + b.Navigation("RawEndpoints"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", b => + { + b.Navigation("JoinedPossibleItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", b => + { + b.Navigation("RawDuelAreas"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", b => + { + b.Navigation("RawAttributes"); + + b.Navigation("RawCharacterClasses"); + + b.Navigation("RawDropItemGroups"); + + b.Navigation("RawGlobalAttributeCombinations"); + + b.Navigation("RawGlobalBaseAttributeValues"); + + b.Navigation("RawItemLevelBonusTables"); + + b.Navigation("RawItemOptionCombinationBonuses"); + + b.Navigation("RawItemOptionTypes"); + + b.Navigation("RawItemOptions"); + + b.Navigation("RawItemSetGroups"); + + b.Navigation("RawItemSlotTypes"); + + b.Navigation("RawItems"); + + b.Navigation("RawJewelMixes"); + + b.Navigation("RawMagicEffects"); + + b.Navigation("RawMaps"); + + b.Navigation("RawMasterSkillRoots"); + + b.Navigation("RawMiniGameDefinitions"); + + b.Navigation("RawMonsters"); + + b.Navigation("RawPlugInConfigurations"); + + b.Navigation("RawSkills"); + + b.Navigation("RawWarpList"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", b => + { + b.Navigation("JoinedDropItemGroups"); + + b.Navigation("RawCharacterPowerUpDefinitions"); + + b.Navigation("RawEnterGates"); + + b.Navigation("RawExitGates"); + + b.Navigation("RawMapRequirements"); + + b.Navigation("RawMonsterSpawns"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfiguration", b => + { + b.Navigation("JoinedMaps"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerDefinition", b => + { + b.Navigation("RawEndpoints"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", b => + { + b.Navigation("RawMembers"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", b => + { + b.Navigation("RawLevelDependentOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", b => + { + b.Navigation("JoinedItemSetGroups"); + + b.Navigation("RawItemOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearance", b => + { + b.Navigation("JoinedVisibleOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", b => + { + b.Navigation("JoinedPossibleItems"); + + b.Navigation("JoinedRequiredItemOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", b => + { + b.Navigation("JoinedPossibleItemOptions"); + + b.Navigation("JoinedPossibleItemSetGroups"); + + b.Navigation("JoinedQualifiedCharacters"); + + b.Navigation("RawBasePowerUpAttributes"); + + b.Navigation("RawDropItems"); + + b.Navigation("RawRequirements"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroup", b => + { + b.Navigation("JoinedPossibleItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", b => + { + b.Navigation("RawBonusPerLevel"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", b => + { + b.Navigation("RawRequirements"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", b => + { + b.Navigation("RawPossibleOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", b => + { + b.Navigation("RawItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", b => + { + b.Navigation("RawItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", b => + { + b.Navigation("RawPowerUpDefinitions"); + + b.Navigation("RawPowerUpDefinitionsPvp"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", b => + { + b.Navigation("JoinedRequiredMasterSkills"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", b => + { + b.Navigation("RawTerrainChanges"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", b => + { + b.Navigation("RawChangeEvents"); + + b.Navigation("RawRewards"); + + b.Navigation("RawSpawnWaves"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", b => + { + b.Navigation("JoinedDropItemGroups"); + + b.Navigation("RawAttributes"); + + b.Navigation("RawBuffs"); + + b.Navigation("RawItemCraftings"); + + b.Navigation("RawQuests"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", b => + { + b.Navigation("RawRelatedValues"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", b => + { + b.Navigation("RawRequiredItems"); + + b.Navigation("RawRequiredMonsterKills"); + + b.Navigation("RawRewards"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", b => + { + b.Navigation("RawRequiredItems"); + + b.Navigation("RawResultItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", b => + { + b.Navigation("JoinedQualifiedCharacters"); + + b.Navigation("RawAttributeRelationships"); + + b.Navigation("RawConsumeRequirements"); + + b.Navigation("RawRequirements"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboDefinition", b => + { + b.Navigation("RawSteps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/20260819113052_AddMiniGameMinimumPlayerCount.cs b/src/Persistence/EntityFramework/Migrations/20260819113052_AddMiniGameMinimumPlayerCount.cs new file mode 100644 index 0000000000..929a491641 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/20260819113052_AddMiniGameMinimumPlayerCount.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations +{ + /// + public partial class AddMiniGameMinimumPlayerCount : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MinimumPlayerCount", + schema: "config", + table: "MiniGameDefinition", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MinimumPlayerCount", + schema: "config", + table: "MiniGameDefinition"); + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs b/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs index b56b2c4b77..9524da8f15 100644 --- a/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs +++ b/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs @@ -2940,6 +2940,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("MinimumCharacterLevel") .HasColumnType("integer"); + b.Property("MinimumPlayerCount") + .HasColumnType("integer"); + b.Property("MinimumSpecialCharacterLevel") .HasColumnType("integer"); diff --git a/src/Persistence/Initialization/Skills/MagicEffectNumber.cs b/src/Persistence/Initialization/Skills/MagicEffectNumber.cs index 8742e32ac7..833257260f 100644 --- a/src/Persistence/Initialization/Skills/MagicEffectNumber.cs +++ b/src/Persistence/Initialization/Skills/MagicEffectNumber.cs @@ -372,6 +372,16 @@ internal enum MagicEffectNumber : short /// Alcohol = 201, + /// + /// The illusion temple "Order of Protection" special skill effect number. + /// + IllusionTempleProtection = 210, + + /// + /// The illusion temple "Restraint" special skill effect number. + /// + IllusionTempleRestraint = 211, + #endregion } diff --git a/src/Persistence/Initialization/VersionSeasonSix/Events/IllusionTempleInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/Events/IllusionTempleInitializer.cs new file mode 100644 index 0000000000..fbc016fc1b --- /dev/null +++ b/src/Persistence/Initialization/VersionSeasonSix/Events/IllusionTempleInitializer.cs @@ -0,0 +1,160 @@ +// +// 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.Attributes; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.Persistence.Initialization.Skills; +using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps; + +/// +/// The initializer for the illusion temple event. +/// +internal class IllusionTempleInitializer : InitializerBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The context. + /// The game configuration. + public IllusionTempleInitializer(IContext context, GameConfiguration gameConfiguration) + : base(context, gameConfiguration) + { + } + + /// + public override void Initialize() + { + var illusionTemple1 = this.CreateIllusionTempleDefinition(1, IllusionTemple1.Number, 3000000); + illusionTemple1.MinimumCharacterLevel = 220; + illusionTemple1.MaximumCharacterLevel = 270; + illusionTemple1.MinimumSpecialCharacterLevel = 220; + illusionTemple1.MaximumSpecialCharacterLevel = 270; + + var illusionTemple2 = this.CreateIllusionTempleDefinition(2, IllusionTemple2.Number, 4000000); + illusionTemple2.MinimumCharacterLevel = 271; + illusionTemple2.MaximumCharacterLevel = 320; + illusionTemple2.MinimumSpecialCharacterLevel = 271; + illusionTemple2.MaximumSpecialCharacterLevel = 320; + + var illusionTemple3 = this.CreateIllusionTempleDefinition(3, IllusionTemple3.Number, 5000000); + illusionTemple3.MinimumCharacterLevel = 321; + illusionTemple3.MaximumCharacterLevel = 350; + illusionTemple3.MinimumSpecialCharacterLevel = 321; + illusionTemple3.MaximumSpecialCharacterLevel = 350; + + var illusionTemple4 = this.CreateIllusionTempleDefinition(4, IllusionTemple4.Number, 6000000); + illusionTemple4.MinimumCharacterLevel = 351; + illusionTemple4.MaximumCharacterLevel = 380; + illusionTemple4.MinimumSpecialCharacterLevel = 351; + illusionTemple4.MaximumSpecialCharacterLevel = 380; + + var illusionTemple5 = this.CreateIllusionTempleDefinition(5, IllusionTemple5.Number, 7000000); + illusionTemple5.MinimumCharacterLevel = 381; + illusionTemple5.MaximumCharacterLevel = 399; + illusionTemple5.MinimumSpecialCharacterLevel = 381; + illusionTemple5.MaximumSpecialCharacterLevel = 399; + + var illusionTemple6 = this.CreateIllusionTempleDefinition(6, IllusionTemple6.Number, 8000000); + illusionTemple6.RequiresMasterClass = true; + illusionTemple6.MinimumCharacterLevel = 0; + illusionTemple6.MaximumCharacterLevel = 400; + illusionTemple6.MinimumSpecialCharacterLevel = 0; + illusionTemple6.MaximumSpecialCharacterLevel = 400; + + this.CreateSpecialSkillEffects(); + } + + /// + /// Creates the magic effects used by the event's special skills (210 - Order of Protection and + /// 211 - Restraint). The other two special skills (212 - Tracking and 213 - Weaken) act instantly + /// and don't need a magic effect of their own. + /// + private void CreateSpecialSkillEffects() + { + var protection = this.Context.CreateNew(); + this.GameConfiguration.MagicEffects.Add(protection); + protection.Number = (short)MagicEffectNumber.IllusionTempleProtection; + protection.Name = "Illusion Temple - Order of Protection"; + protection.InformObservers = true; + protection.StopByDeath = true; + protection.Duration = this.Context.CreateNew(); + protection.Duration.ConstantValue!.Value = 15; // 15 seconds + + var protectionPowerUp = this.Context.CreateNew(); + protection.PowerUpDefinitions.Add(protectionPowerUp); + protectionPowerUp.TargetAttribute = Stats.DamageReceiveDecrement.GetPersistent(this.GameConfiguration); + protectionPowerUp.Boost = this.Context.CreateNew(); + protectionPowerUp.Boost.ConstantValue.Value = 0.50f; // 50 % damage reduction + protectionPowerUp.Boost.ConstantValue.AggregateType = AggregateType.Multiplicate; + + var restraint = this.Context.CreateNew(); + this.GameConfiguration.MagicEffects.Add(restraint); + restraint.Number = (short)MagicEffectNumber.IllusionTempleRestraint; + restraint.Name = "Illusion Temple - Restraint"; + restraint.InformObservers = true; + restraint.StopByDeath = true; + restraint.Duration = this.Context.CreateNew(); + restraint.Duration.ConstantValue!.Value = 15; // 15 seconds + + var restraintPowerUp = this.Context.CreateNew(); + restraint.PowerUpDefinitions.Add(restraintPowerUp); + restraintPowerUp.TargetAttribute = Stats.IsFrozen.GetPersistent(this.GameConfiguration); + restraintPowerUp.Boost = this.Context.CreateNew(); + restraintPowerUp.Boost.ConstantValue.Value = 1; + } + + /// + /// Creates a new for a illusion temple event. + /// + /// The level of the event. + /// The map number. + /// The entrance fee. + /// + /// The created . + /// + protected MiniGameDefinition CreateIllusionTempleDefinition(byte level, short mapNumber, int entranceFee) + { + var illusionTemple = this.Context.CreateNew(); + illusionTemple.SetGuid((short)MiniGameType.IllusionTemple, level); + this.GameConfiguration.MiniGameDefinitions.Add(illusionTemple); + illusionTemple.Name = $"Illusion Temple {level}"; + illusionTemple.Description = $"Event definition for illusion temple, level {level}."; + illusionTemple.EnterDuration = TimeSpan.FromMinutes(5); + illusionTemple.GameDuration = TimeSpan.FromMinutes(15); + illusionTemple.ExitDuration = TimeSpan.FromMinutes(1); + illusionTemple.MaximumPlayerCount = 10; // reduce it for small servers 4 + illusionTemple.MinimumPlayerCount = 2; + illusionTemple.Entrance = this.GameConfiguration.Maps + .First(m => m.Number == mapNumber) + .ExitGates + .Where(g => g.IsSpawnGate) + .OrderBy(g => g.X1).ThenBy(g => g.Y1) + .First(); + illusionTemple.Type = MiniGameType.IllusionTemple; + illusionTemple.TicketItem = this.GameConfiguration.Items.Single(item => item is { Group: 13, Number: 51 }); + illusionTemple.TicketItemLevel = level; + illusionTemple.GameLevel = level; + illusionTemple.MapCreationPolicy = MiniGameMapCreationPolicy.Shared; + illusionTemple.SaveRankingStatistics = true; + illusionTemple.EntranceFee = entranceFee; + illusionTemple.AllowParty = false; + + this.CreateRewards(level, illusionTemple); + + return illusionTemple; + } + + private void CreateRewards(byte level, MiniGameDefinition illusionTemple) + { + var winnerExpReward = this.Context.CreateNew(); + winnerExpReward.RewardType = MiniGameRewardType.Experience; + winnerExpReward.RewardAmount = 100_000 * level; + winnerExpReward.RequiredSuccess = MiniGameSuccessFlags.WinnerOrInWinningParty; + illusionTemple.Rewards.Add(winnerExpReward); + } +} \ No newline at end of file diff --git a/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs index d95ae5595e..9ea45cefba 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs @@ -90,6 +90,7 @@ public override void Initialize() new BloodCastleInitializer(this.Context, this.GameConfiguration).Initialize(); new ChaosCastleInitializer(this.Context, this.GameConfiguration).Initialize(); new CastleSiegeInitializer(this.Context, this.GameConfiguration).Initialize(); + new IllusionTempleInitializer(this.Context, this.GameConfiguration).Initialize(); } /// diff --git a/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs b/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs index ba77909fee..959061e959 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs @@ -246,6 +246,8 @@ public override void Initialize() } { + // Holds the sacred relic during an illusion temple match. It's not killable in combat - + // talking to it while standing close breaks it instantly and drops the relic on the ground. var def = this.Context.CreateNew(); def.Number = 380; def.Designation = "Stone Statue"; @@ -294,11 +296,14 @@ public override void Initialize() var def = this.Context.CreateNew(); def.Number = 385; def.Designation = "Mirage"; + def.NpcWindow = NpcWindow.IllusionTemple; def.ObjectKind = NpcObjectKind.PassiveNpc; this.GameConfiguration.Monsters.Add(def); def.SetGuid(def.Number); } + this.CreateIllusionSorcererSpirits(); + { var def = this.Context.CreateNew(); def.Number = 215; @@ -1121,4 +1126,58 @@ public override void Initialize() this.GameConfiguration.Monsters.Add(def); } } + + /// + /// Creates the "Illusion Sorc. Spirit" monsters (386-399) which roam the arena of the six illusion + /// temples - killing them grants skill points for the event's special skills. Each temple level uses + /// its own set of three (temple 5: two) increasingly stronger variants. + /// + private void CreateIllusionSorcererSpirits() + { + (short Number, int Level, int Hp, int MinDmg, int MaxDmg, int Defense, int AttackRate, int DefenseRate, byte AttackRange, short ViewRange, int MoveDelayMs, int AttackDelayMs)[] spirits = + { + (386, 65, 7150, 195, 245, 150, 340, 98, 4, 4, 800, 1600), + (387, 65, 7150, 215, 265, 170, 380, 110, 4, 4, 800, 1600), + (388, 67, 7370, 235, 285, 190, 440, 130, 1, 6, 1600, 2000), + (389, 70, 8680, 280, 330, 210, 500, 150, 4, 4, 800, 1600), + (390, 70, 8680, 300, 350, 230, 560, 170, 4, 4, 800, 1600), + (391, 72, 8928, 320, 370, 250, 640, 200, 1, 6, 1600, 2000), + (392, 75, 15000, 375, 395, 280, 460, 150, 4, 4, 800, 1600), + (393, 75, 15000, 395, 415, 300, 520, 160, 4, 4, 800, 1600), + (394, 77, 15400, 415, 435, 320, 580, 195, 1, 6, 1600, 2000), + (395, 80, 19200, 480, 500, 360, 660, 230, 4, 4, 800, 1600), + (396, 80, 19200, 500, 520, 380, 720, 260, 4, 4, 800, 1600), + (397, 82, 19680, 520, 540, 400, 840, 280, 1, 6, 1600, 2000), + (398, 85, 25500, 595, 615, 450, 760, 275, 4, 4, 800, 1600), + (399, 85, 25500, 615, 635, 470, 820, 303, 4, 4, 800, 1600), + }; + + foreach (var spirit in spirits) + { + var def = this.Context.CreateNew(); + def.Number = spirit.Number; + def.Designation = "Illusion Sorc. Spirit"; + def.MoveRange = 3; + def.AttackRange = spirit.AttackRange; + def.ViewRange = spirit.ViewRange; + def.MoveDelay = TimeSpan.FromMilliseconds(spirit.MoveDelayMs); + def.AttackDelay = TimeSpan.FromMilliseconds(spirit.AttackDelayMs); + def.RespawnDelay = TimeSpan.FromSeconds(10); + def.Attribute = 2; + def.NumberOfMaximumItemDrops = 1; + var attributes = new Dictionary + { + { Stats.Level, spirit.Level }, + { Stats.MaximumHealth, spirit.Hp }, + { Stats.MinimumPhysBaseDmg, spirit.MinDmg }, + { Stats.MaximumPhysBaseDmg, spirit.MaxDmg }, + { Stats.DefenseBase, spirit.Defense }, + { Stats.AttackRatePvm, spirit.AttackRate }, + { Stats.DefenseRatePvm, spirit.DefenseRate }, + }; + def.AddAttributes(attributes, this.Context, this.GameConfiguration); + this.GameConfiguration.Monsters.Add(def); + def.SetGuid(def.Number); + } + } } \ No newline at end of file diff --git a/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs b/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs index e694014660..92137ef7f4 100644 --- a/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs +++ b/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs @@ -5391,32 +5391,32 @@ public void IllusionTempleState_PacketSizeValidation() Assert.That(4, Is.GreaterThanOrEqualTo(0), "Field 'RemainingSeconds' has invalid negative index"); - // Field 'PlayerIndex' starts at index 4 with size 2 - Assert.That(4, Is.GreaterThanOrEqualTo(0), - "Field 'PlayerIndex' has invalid negative index"); - - // Field 'PositionX' starts at index 6 with size 1 + // Field 'RelicCarrierId' starts at index 6 with size 2 Assert.That(6, Is.GreaterThanOrEqualTo(0), - "Field 'PositionX' has invalid negative index"); + "Field 'RelicCarrierId' has invalid negative index"); - // Field 'PositionY' starts at index 7 with size 1 - Assert.That(7, Is.GreaterThanOrEqualTo(0), - "Field 'PositionY' has invalid negative index"); - - // Field 'Team1Points' starts at index 8 with size 1 + // Field 'PositionX' starts at index 8 with size 1 Assert.That(8, Is.GreaterThanOrEqualTo(0), - "Field 'Team1Points' has invalid negative index"); + "Field 'PositionX' has invalid negative index"); - // Field 'Team2Points' starts at index 9 with size 1 + // Field 'PositionY' starts at index 9 with size 1 Assert.That(9, Is.GreaterThanOrEqualTo(0), - "Field 'Team2Points' has invalid negative index"); + "Field 'PositionY' has invalid negative index"); - // Field 'MyTeam' starts at index 10 with size 1 + // Field 'AlliedForcesPoints' starts at index 10 with size 1 Assert.That(10, Is.GreaterThanOrEqualTo(0), - "Field 'MyTeam' has invalid negative index"); + "Field 'AlliedForcesPoints' has invalid negative index"); - // Field 'PartyCount' starts at index 11 with size 1 + // Field 'IllusionForcesPoints' starts at index 11 with size 1 Assert.That(11, Is.GreaterThanOrEqualTo(0), + "Field 'IllusionForcesPoints' has invalid negative index"); + + // Field 'MyTeam' starts at index 12 with size 1 + Assert.That(12, Is.GreaterThanOrEqualTo(0), + "Field 'MyTeam' has invalid negative index"); + + // Field 'PartyCount' starts at index 13 with size 1 + Assert.That(13, Is.GreaterThanOrEqualTo(0), "Field 'PartyCount' has invalid negative index"); } @@ -5566,6 +5566,28 @@ public void IllusionTempleHolyItemRelics_PacketSizeValidation() "GetRequiredSize calculation incorrect for string field"); } + /// + /// Tests the packet size calculation for IllusionTempleEventState. + /// + [Test] + public void IllusionTempleEventState_PacketSizeValidation() + { + // Fixed-length packet validation + const int expectedLength = 6; + var actualLength = IllusionTempleEventStateRef.Length; + + Assert.That(actualLength, Is.EqualTo(expectedLength), + "Packet length mismatch: declared length does not match calculated size"); + + // Validate field 'TempleNumber' boundary + Assert.That(4 + 1, Is.LessThanOrEqualTo(expectedLength), + "Field 'TempleNumber' exceeds packet boundary"); + + // Validate field 'State' boundary + Assert.That(5 + 1, Is.LessThanOrEqualTo(expectedLength), + "Field 'State' exceeds packet boundary"); + } + /// /// Tests the packet size calculation for IllusionTempleSkillEnd. /// From 03d965eaca45e24a3b0c04a78ad4a7a81dfa6413 Mon Sep 17 00:00:00 2001 From: bulgarashi Date: Sun, 23 Aug 2026 13:58:17 +0200 Subject: [PATCH 02/11] Schedule Illusion Temple starts and add a GM chat command to force one Registers the periodic mini game start plugin (every 2 hours, matching the official Webzen server's Illusion Temple schedule) and its game server state tracking, plus a chat command for game masters to start an Illusion Temple match on demand for testing. Co-Authored-By: Claude Sonnet 5 --- ...artIllusionTempleEventChatCommandPlugIn.cs | 34 +++++++++++++++++++ .../IllusionTempleGameServerState.cs | 23 +++++++++++++ .../IllusionTempleStartConfiguration.cs | 24 +++++++++++++ .../IllusionTempleStartPlugin.cs | 33 ++++++++++++++++++ .../Properties/PlugInResources.Designer.cs | 20 ++++++++++- src/GameLogic/Properties/PlugInResources.resx | 12 +++++++ .../Properties/PlugInResources.Designer.cs | 27 +++++++++++++++ .../Properties/PlugInResources.resx | 6 ++++ 8 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 src/GameLogic/PlugIns/ChatCommands/StartIllusionTempleEventChatCommandPlugIn.cs create mode 100644 src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleGameServerState.cs create mode 100644 src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleStartConfiguration.cs create mode 100644 src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleStartPlugin.cs diff --git a/src/GameLogic/PlugIns/ChatCommands/StartIllusionTempleEventChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/StartIllusionTempleEventChatCommandPlugIn.cs new file mode 100644 index 0000000000..51d7490bd9 --- /dev/null +++ b/src/GameLogic/PlugIns/ChatCommands/StartIllusionTempleEventChatCommandPlugIn.cs @@ -0,0 +1,34 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.PlugIns; + +/// +/// A chat command plugin which handles the startcc command. +/// +[Guid("A990270E-B9C6-4445-BBA9-56367A90D42D")] +[PlugIn] +[Display(Name = nameof(PlugInResources.StartIllusionTempleEventChatCommandPlugIn_Name), Description = nameof(PlugInResources.StartIllusionTempleEventChatCommandPlugIn_Description), ResourceType = typeof(PlugInResources))] +[ChatCommandHelp(Command, CharacterStatus.GameMaster)] +public class StartIllusionTempleEventChatCommandPlugIn : IChatCommandPlugIn +{ + private const string Command = "/startit"; + + /// + public string Key => Command; + + /// + public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster; + + /// + public async ValueTask HandleCommandAsync(Player player, string command) + { + var illusionTemple = player.GameContext.PlugInManager.GetStrategy(MiniGameType.IllusionTemple); + illusionTemple?.ForceStart(); + } +} \ No newline at end of file diff --git a/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleGameServerState.cs b/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleGameServerState.cs new file mode 100644 index 0000000000..7f05f30774 --- /dev/null +++ b/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleGameServerState.cs @@ -0,0 +1,23 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; + +/// +/// The state of a game server state for a chaos castle event. +/// +public class IllusionTempleGameServerState : PeriodicTaskGameServerState +{ + /// + /// Initializes a new instance of the class. + /// + /// The context. + public IllusionTempleGameServerState(IGameContext context) + : base(context) + { + } + + /// + public override string Description => "Illusion Temple"; +} \ No newline at end of file diff --git a/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleStartConfiguration.cs b/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleStartConfiguration.cs new file mode 100644 index 0000000000..33ba4fadf9 --- /dev/null +++ b/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleStartConfiguration.cs @@ -0,0 +1,24 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; + +/// +/// The Illusion temple start configuration. +/// +public class IllusionTempleStartConfiguration : MiniGameStartConfiguration +{ + /// + /// Gets the default configuration for Illusion Temple. + /// + public static IllusionTempleStartConfiguration Default => + new() + { + PreStartMessageDelay = TimeSpan.Zero, + EntranceOpenedMessage = "Illusion Temple entrance is open and closes in {0} minute(s).", + EntranceClosedMessage = "Illusion Temple entrance closed.", + TaskDuration = TimeSpan.FromMinutes(15), + Timetable = PeriodicTaskConfiguration.GenerateTimeSequence(TimeSpan.FromMinutes(120)).ToList(), + }; +} \ No newline at end of file diff --git a/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleStartPlugin.cs b/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleStartPlugin.cs new file mode 100644 index 0000000000..358ba0bac3 --- /dev/null +++ b/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleStartPlugin.cs @@ -0,0 +1,33 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.PlugIns; + +/// +/// This plugin enables the start of the illusion temple. +/// +[PlugIn] +[Display(Name = nameof(IllusionTempleStartPlugin), Description = "Illusion Temple event")] +[Guid("3AD96A70-ED24-4979-80B8-169E464E545F")] +public sealed class IllusionTempleStartPlugin : MiniGameStartBasePlugIn +{ + /// + public override MiniGameType Key => MiniGameType.IllusionTemple; + + /// + public override object CreateDefaultConfig() + { + return IllusionTempleStartConfiguration.Default; + } + + /// + protected override IllusionTempleGameServerState CreateState(IGameContext gameContext) + { + return new IllusionTempleGameServerState(gameContext); + } +} \ No newline at end of file diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index dc41811fa6..2fec50e949 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -2770,7 +2770,25 @@ public static string StartChaosCastleEventChatCommandPlugIn_Name { return ResourceManager.GetString("StartChaosCastleEventChatCommandPlugIn_Name", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Handles the chat command '/startit'. Starts the illusion temple event at the next possible time.. + /// + public static string StartIllusionTempleEventChatCommandPlugIn_Description { + get { + return ResourceManager.GetString("StartIllusionTempleEventChatCommandPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Start Illusion Temple Event Chat Command. + /// + public static string StartIllusionTempleEventChatCommandPlugIn_Name { + get { + return ResourceManager.GetString("StartIllusionTempleEventChatCommandPlugIn_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Handles the chat command '/startds'. Starts the devil square event at the next possible time.. /// diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 26953515c4..7200fa9519 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -705,6 +705,12 @@ Handles the chat command '/startcc'. Starts the chaos castle event at the next possible time. + + Start Illusion Temple Event Chat Command + + + Handles the chat command '/startit'. Starts the illusion temple event at the next possible time. + Start Devil Square Event Chat Command @@ -1242,4 +1248,10 @@ Log out + + Start Illusion Temple Event Chat Command + + + Starts the Illusion Temple event. + diff --git a/src/GameServer/Properties/PlugInResources.Designer.cs b/src/GameServer/Properties/PlugInResources.Designer.cs index 9919d2fab3..9b3f6998bb 100644 --- a/src/GameServer/Properties/PlugInResources.Designer.cs +++ b/src/GameServer/Properties/PlugInResources.Designer.cs @@ -1094,6 +1094,33 @@ public static string ChaosCastleStateViewPlugIn_Description { return ResourceManager.GetString("ChaosCastleStateViewPlugIn_Description", resourceCulture); } } + + /// + /// Looks up a localized string similar to IllusionTemple Enter Handler. + /// + public static string IllusionTempleEnterHandlerPlugIn_Description { + get { + return ResourceManager.GetString("IllusionTempleEnterHandlerPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to IllusionTemple Enter Handler. + /// + public static string IllusionTempleEnterHandlerPlugIn_Name { + get { + return ResourceManager.GetString("IllusionTempleEnterHandlerPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The default implementation of the IChaosCastleStateViewPlugin which is forwarding everything to the game client with specific data packets.. + /// + public static string IllusionTempleStateViewPlugIn_Description { + get { + return ResourceManager.GetString("IllusionTempleStateViewPlugIn_Description", resourceCulture); + } + } /// /// Looks up a localized string similar to Chaos Castle State View. diff --git a/src/GameServer/Properties/PlugInResources.resx b/src/GameServer/Properties/PlugInResources.resx index 344788335a..8e687196bc 100644 --- a/src/GameServer/Properties/PlugInResources.resx +++ b/src/GameServer/Properties/PlugInResources.resx @@ -591,6 +591,12 @@ Handler for chaos castle enter request packets. + + IllusionTemple Enter Handler + + + Handler for illusion temple enter request packets. + Devil Square Enter Handler From 664cbe1cfa6bcc8e7929f18d3160b4a9fc76431e Mon Sep 17 00:00:00 2001 From: bulgarashi Date: Sun, 23 Aug 2026 13:58:28 +0200 Subject: [PATCH 03/11] Fix Illusion Temple map spawn data and safezone map Corrects the statue/guardian/relic-box spawn data on all six temple maps, verified against a working Season 6 Episode 3 server's spawn list: two stone statue positions (not three), added the two decorative team guardian NPCs (381/382), and fixed both relic storage box coordinates, which were off by one tile. Adds the 32 roaming "Illusion Sorc. Spirit" arena monster spawns (NPC 386-399, cycling per temple level) to temples 1-5 - temple 6 has none, matching the reference data. Also sets each temple's safezone map to Devias explicitly: without it, a temple's own spawn gate made BaseMapInitializer default the safezone to the temple map itself, so a player warped to "safezone" (e.g. when too few players joined) was simply sent back into the arena instead of actually leaving it. Co-Authored-By: Claude Sonnet 5 --- .../VersionSeasonSix/Maps/IllusionTemple1.cs | 50 +++++++++++++++---- .../VersionSeasonSix/Maps/IllusionTemple2.cs | 50 +++++++++++++++---- .../VersionSeasonSix/Maps/IllusionTemple3.cs | 50 +++++++++++++++---- .../VersionSeasonSix/Maps/IllusionTemple4.cs | 50 +++++++++++++++---- .../VersionSeasonSix/Maps/IllusionTemple5.cs | 50 +++++++++++++++---- .../VersionSeasonSix/Maps/IllusionTemple6.cs | 29 +++++++---- 6 files changed, 213 insertions(+), 66 deletions(-) diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple1.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple1.cs index b5f1e83ae4..83474910ab 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple1.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple1.cs @@ -37,23 +37,51 @@ public IllusionTemple1(IContext context, GameConfiguration gameConfiguration) /// protected override string MapName => Name; + /// + protected override byte SafezoneMapNumber => Devias.Number; + /// protected override IEnumerable CreateMonsterSpawns() { // NPCs: - yield return this.CreateMonsterSpawn(100, this.NpcDictionary[658], 169, 085, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Cursed Statue - yield return this.CreateMonsterSpawn(101, this.NpcDictionary[659], 136, 101, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (1) - yield return this.CreateMonsterSpawn(102, this.NpcDictionary[660], 151, 119, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (2) - yield return this.CreateMonsterSpawn(103, this.NpcDictionary[661], 150, 088, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (3) - yield return this.CreateMonsterSpawn(104, this.NpcDictionary[662], 165, 102, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (4) - yield return this.CreateMonsterSpawn(105, this.NpcDictionary[663], 173, 067, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (5) - yield return this.CreateMonsterSpawn(106, this.NpcDictionary[664], 187, 081, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (6) - yield return this.CreateMonsterSpawn(107, this.NpcDictionary[665], 187, 051, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (7) - yield return this.CreateMonsterSpawn(108, this.NpcDictionary[666], 203, 067, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (8) - yield return this.CreateMonsterSpawn(109, this.NpcDictionary[667], 133, 121, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (9) - yield return this.CreateMonsterSpawn(110, this.NpcDictionary[668], 206, 048, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (10) + // Pool of Stone Statue (380) positions - only one of them is active at a time, randomly picked + // from these two by the game logic. Positions confirmed against a working Season 6 Episode 3 + // server's spawn list. + yield return this.CreateMonsterSpawn(100, this.NpcDictionary[380], 207, 047, Direction.Undefined, SpawnTrigger.ManuallyForEvent); + yield return this.CreateMonsterSpawn(101, this.NpcDictionary[380], 134, 121, Direction.Undefined, SpawnTrigger.ManuallyForEvent); + + // Team guardians (decorative). + yield return this.CreateMonsterSpawn(110, this.NpcDictionary[381], 139, 046, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // MU Allies General + yield return this.CreateMonsterSpawn(111, this.NpcDictionary[382], 194, 123, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Illusion Elder + + // Relic delivery targets - the team which carries the relic here scores a point. + yield return this.CreateMonsterSpawn(112, this.NpcDictionary[383], 141, 059, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Alliance Item Storage + yield return this.CreateMonsterSpawn(113, this.NpcDictionary[384], 194, 113, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Illusion Item Storage + + // Roaming "Illusion Sorc. Spirit" monsters (386-388) - killing them grants skill points for the + // event's special skills. + for (var i = 0; i < SorcererSpiritPositions.Length; i++) + { + var (x, y) = SorcererSpiritPositions[i]; + yield return this.CreateMonsterSpawn((short)(120 + i), this.NpcDictionary[(short)(386 + (i % 3))], x, y, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); + } } + private static readonly (byte X, byte Y)[] SorcererSpiritPositions = + { + (131, 93), (131, 89), (131, 85), + (168, 123), (164, 123), (160, 123), + (169, 48), (169, 52), (169, 56), + (206, 85), (206, 81), (206, 77), + (158, 85), (168, 94), (169, 75), + (179, 85), (157, 66), (162, 66), + (150, 78), (150, 73), (176, 103), + (181, 103), (187, 98), (187, 92), + (197, 57), (141, 113), (193, 61), + (145, 109), (189, 65), (149, 105), + (167, 87), (171, 83), + }; + /// protected override void CreateMonsters() { diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple2.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple2.cs index a8831e7065..6780dbe9c1 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple2.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple2.cs @@ -37,23 +37,51 @@ public IllusionTemple2(IContext context, GameConfiguration gameConfiguration) /// protected override string MapName => Name; + /// + protected override byte SafezoneMapNumber => Devias.Number; + /// protected override IEnumerable CreateMonsterSpawns() { // NPCs: - yield return this.CreateMonsterSpawn(100, this.NpcDictionary[658], 169, 085, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Cursed Statue - yield return this.CreateMonsterSpawn(101, this.NpcDictionary[659], 136, 101, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (1) - yield return this.CreateMonsterSpawn(102, this.NpcDictionary[660], 151, 119, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (2) - yield return this.CreateMonsterSpawn(103, this.NpcDictionary[661], 150, 088, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (3) - yield return this.CreateMonsterSpawn(104, this.NpcDictionary[662], 165, 102, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (4) - yield return this.CreateMonsterSpawn(105, this.NpcDictionary[663], 173, 067, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (5) - yield return this.CreateMonsterSpawn(106, this.NpcDictionary[664], 187, 081, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (6) - yield return this.CreateMonsterSpawn(107, this.NpcDictionary[665], 187, 051, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (7) - yield return this.CreateMonsterSpawn(108, this.NpcDictionary[666], 203, 067, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (8) - yield return this.CreateMonsterSpawn(109, this.NpcDictionary[667], 133, 121, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (9) - yield return this.CreateMonsterSpawn(110, this.NpcDictionary[668], 206, 048, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (10) + // Pool of Stone Statue (380) positions - only one of them is active at a time, randomly picked + // from these two by the game logic. Positions confirmed against a working Season 6 Episode 3 + // server's spawn list. + yield return this.CreateMonsterSpawn(100, this.NpcDictionary[380], 207, 047, Direction.Undefined, SpawnTrigger.ManuallyForEvent); + yield return this.CreateMonsterSpawn(101, this.NpcDictionary[380], 134, 121, Direction.Undefined, SpawnTrigger.ManuallyForEvent); + + // Team guardians (decorative). + yield return this.CreateMonsterSpawn(110, this.NpcDictionary[381], 139, 046, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // MU Allies General + yield return this.CreateMonsterSpawn(111, this.NpcDictionary[382], 194, 123, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Illusion Elder + + // Relic delivery targets - the team which carries the relic here scores a point. + yield return this.CreateMonsterSpawn(112, this.NpcDictionary[383], 141, 059, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Alliance Item Storage + yield return this.CreateMonsterSpawn(113, this.NpcDictionary[384], 194, 113, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Illusion Item Storage + + // Roaming "Illusion Sorc. Spirit" monsters (389-391) - killing them grants skill points for the + // event's special skills. + for (var i = 0; i < SorcererSpiritPositions.Length; i++) + { + var (x, y) = SorcererSpiritPositions[i]; + yield return this.CreateMonsterSpawn((short)(120 + i), this.NpcDictionary[(short)(389 + (i % 3))], x, y, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); + } } + private static readonly (byte X, byte Y)[] SorcererSpiritPositions = + { + (131, 93), (131, 89), (131, 85), + (168, 123), (164, 123), (160, 123), + (169, 48), (169, 52), (169, 56), + (206, 85), (206, 81), (206, 77), + (158, 85), (168, 94), (169, 75), + (179, 85), (157, 66), (162, 66), + (150, 78), (150, 73), (176, 103), + (181, 103), (187, 98), (187, 92), + (197, 57), (141, 113), (193, 61), + (145, 109), (189, 65), (149, 105), + (167, 87), (171, 83), + }; + /// protected override void CreateMonsters() { diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple3.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple3.cs index 8087b2fcae..055173d4d2 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple3.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple3.cs @@ -37,23 +37,51 @@ public IllusionTemple3(IContext context, GameConfiguration gameConfiguration) /// protected override string MapName => Name; + /// + protected override byte SafezoneMapNumber => Devias.Number; + /// protected override IEnumerable CreateMonsterSpawns() { // NPCs: - yield return this.CreateMonsterSpawn(100, this.NpcDictionary[658], 169, 085, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Cursed Statue - yield return this.CreateMonsterSpawn(101, this.NpcDictionary[659], 136, 101, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (1) - yield return this.CreateMonsterSpawn(102, this.NpcDictionary[660], 151, 119, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (2) - yield return this.CreateMonsterSpawn(103, this.NpcDictionary[661], 150, 088, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (3) - yield return this.CreateMonsterSpawn(104, this.NpcDictionary[662], 165, 102, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (4) - yield return this.CreateMonsterSpawn(105, this.NpcDictionary[663], 173, 067, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (5) - yield return this.CreateMonsterSpawn(106, this.NpcDictionary[664], 187, 081, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (6) - yield return this.CreateMonsterSpawn(107, this.NpcDictionary[665], 187, 051, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (7) - yield return this.CreateMonsterSpawn(108, this.NpcDictionary[666], 203, 067, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (8) - yield return this.CreateMonsterSpawn(109, this.NpcDictionary[667], 133, 121, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (9) - yield return this.CreateMonsterSpawn(110, this.NpcDictionary[668], 206, 048, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (10) + // Pool of Stone Statue (380) positions - only one of them is active at a time, randomly picked + // from these two by the game logic. Positions confirmed against a working Season 6 Episode 3 + // server's spawn list. + yield return this.CreateMonsterSpawn(100, this.NpcDictionary[380], 207, 047, Direction.Undefined, SpawnTrigger.ManuallyForEvent); + yield return this.CreateMonsterSpawn(101, this.NpcDictionary[380], 134, 121, Direction.Undefined, SpawnTrigger.ManuallyForEvent); + + // Team guardians (decorative). + yield return this.CreateMonsterSpawn(110, this.NpcDictionary[381], 139, 046, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // MU Allies General + yield return this.CreateMonsterSpawn(111, this.NpcDictionary[382], 194, 123, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Illusion Elder + + // Relic delivery targets - the team which carries the relic here scores a point. + yield return this.CreateMonsterSpawn(112, this.NpcDictionary[383], 141, 059, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Alliance Item Storage + yield return this.CreateMonsterSpawn(113, this.NpcDictionary[384], 194, 113, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Illusion Item Storage + + // Roaming "Illusion Sorc. Spirit" monsters (392-394) - killing them grants skill points for the + // event's special skills. + for (var i = 0; i < SorcererSpiritPositions.Length; i++) + { + var (x, y) = SorcererSpiritPositions[i]; + yield return this.CreateMonsterSpawn((short)(120 + i), this.NpcDictionary[(short)(392 + (i % 3))], x, y, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); + } } + private static readonly (byte X, byte Y)[] SorcererSpiritPositions = + { + (131, 93), (131, 89), (131, 85), + (168, 123), (164, 123), (160, 123), + (169, 48), (169, 52), (169, 56), + (206, 85), (206, 81), (206, 77), + (158, 85), (168, 94), (169, 75), + (179, 85), (157, 66), (162, 66), + (150, 78), (150, 73), (176, 103), + (181, 103), (187, 98), (187, 92), + (197, 57), (141, 113), (193, 61), + (145, 109), (189, 65), (149, 105), + (167, 87), (171, 83), + }; + /// protected override void CreateMonsters() { diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple4.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple4.cs index d38200813a..20f262ccb1 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple4.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple4.cs @@ -37,23 +37,51 @@ public IllusionTemple4(IContext context, GameConfiguration gameConfiguration) /// protected override string MapName => Name; + /// + protected override byte SafezoneMapNumber => Devias.Number; + /// protected override IEnumerable CreateMonsterSpawns() { // NPCs: - yield return this.CreateMonsterSpawn(100, this.NpcDictionary[658], 169, 085, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Cursed Statue - yield return this.CreateMonsterSpawn(101, this.NpcDictionary[659], 136, 101, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (1) - yield return this.CreateMonsterSpawn(102, this.NpcDictionary[660], 151, 119, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (2) - yield return this.CreateMonsterSpawn(103, this.NpcDictionary[661], 150, 088, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (3) - yield return this.CreateMonsterSpawn(104, this.NpcDictionary[662], 165, 102, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (4) - yield return this.CreateMonsterSpawn(105, this.NpcDictionary[663], 173, 067, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (5) - yield return this.CreateMonsterSpawn(106, this.NpcDictionary[664], 187, 081, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (6) - yield return this.CreateMonsterSpawn(107, this.NpcDictionary[665], 187, 051, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (7) - yield return this.CreateMonsterSpawn(108, this.NpcDictionary[666], 203, 067, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (8) - yield return this.CreateMonsterSpawn(109, this.NpcDictionary[667], 133, 121, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (9) - yield return this.CreateMonsterSpawn(110, this.NpcDictionary[668], 206, 048, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (10) + // Pool of Stone Statue (380) positions - only one of them is active at a time, randomly picked + // from these two by the game logic. Positions confirmed against a working Season 6 Episode 3 + // server's spawn list. + yield return this.CreateMonsterSpawn(100, this.NpcDictionary[380], 207, 047, Direction.Undefined, SpawnTrigger.ManuallyForEvent); + yield return this.CreateMonsterSpawn(101, this.NpcDictionary[380], 134, 121, Direction.Undefined, SpawnTrigger.ManuallyForEvent); + + // Team guardians (decorative). + yield return this.CreateMonsterSpawn(110, this.NpcDictionary[381], 139, 046, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // MU Allies General + yield return this.CreateMonsterSpawn(111, this.NpcDictionary[382], 194, 123, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Illusion Elder + + // Relic delivery targets - the team which carries the relic here scores a point. + yield return this.CreateMonsterSpawn(112, this.NpcDictionary[383], 141, 059, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Alliance Item Storage + yield return this.CreateMonsterSpawn(113, this.NpcDictionary[384], 194, 113, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Illusion Item Storage + + // Roaming "Illusion Sorc. Spirit" monsters (395-397) - killing them grants skill points for the + // event's special skills. + for (var i = 0; i < SorcererSpiritPositions.Length; i++) + { + var (x, y) = SorcererSpiritPositions[i]; + yield return this.CreateMonsterSpawn((short)(120 + i), this.NpcDictionary[(short)(395 + (i % 3))], x, y, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); + } } + private static readonly (byte X, byte Y)[] SorcererSpiritPositions = + { + (131, 93), (131, 89), (131, 85), + (168, 123), (164, 123), (160, 123), + (169, 48), (169, 52), (169, 56), + (206, 85), (206, 81), (206, 77), + (158, 85), (168, 94), (169, 75), + (179, 85), (157, 66), (162, 66), + (150, 78), (150, 73), (176, 103), + (181, 103), (187, 98), (187, 92), + (197, 57), (141, 113), (193, 61), + (145, 109), (189, 65), (149, 105), + (167, 87), (171, 83), + }; + /// protected override void CreateMonsters() { diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple5.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple5.cs index 1728642a17..9445a3719d 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple5.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple5.cs @@ -37,23 +37,51 @@ public IllusionTemple5(IContext context, GameConfiguration gameConfiguration) /// protected override string MapName => Name; + /// + protected override byte SafezoneMapNumber => Devias.Number; + /// protected override IEnumerable CreateMonsterSpawns() { // NPCs: - yield return this.CreateMonsterSpawn(100, this.NpcDictionary[658], 169, 085, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Cursed Statue - yield return this.CreateMonsterSpawn(101, this.NpcDictionary[659], 136, 101, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (1) - yield return this.CreateMonsterSpawn(102, this.NpcDictionary[660], 151, 119, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (2) - yield return this.CreateMonsterSpawn(103, this.NpcDictionary[661], 150, 088, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (3) - yield return this.CreateMonsterSpawn(104, this.NpcDictionary[662], 165, 102, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (4) - yield return this.CreateMonsterSpawn(105, this.NpcDictionary[663], 173, 067, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (5) - yield return this.CreateMonsterSpawn(106, this.NpcDictionary[664], 187, 081, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (6) - yield return this.CreateMonsterSpawn(107, this.NpcDictionary[665], 187, 051, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (7) - yield return this.CreateMonsterSpawn(108, this.NpcDictionary[666], 203, 067, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (8) - yield return this.CreateMonsterSpawn(109, this.NpcDictionary[667], 133, 121, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (9) - yield return this.CreateMonsterSpawn(110, this.NpcDictionary[668], 206, 048, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (10) + // Pool of Stone Statue (380) positions - only one of them is active at a time, randomly picked + // from these two by the game logic. Positions confirmed against a working Season 6 Episode 3 + // server's spawn list. + yield return this.CreateMonsterSpawn(100, this.NpcDictionary[380], 207, 047, Direction.Undefined, SpawnTrigger.ManuallyForEvent); + yield return this.CreateMonsterSpawn(101, this.NpcDictionary[380], 134, 121, Direction.Undefined, SpawnTrigger.ManuallyForEvent); + + // Team guardians (decorative). + yield return this.CreateMonsterSpawn(110, this.NpcDictionary[381], 139, 046, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // MU Allies General + yield return this.CreateMonsterSpawn(111, this.NpcDictionary[382], 194, 123, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Illusion Elder + + // Relic delivery targets - the team which carries the relic here scores a point. + yield return this.CreateMonsterSpawn(112, this.NpcDictionary[383], 141, 059, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Alliance Item Storage + yield return this.CreateMonsterSpawn(113, this.NpcDictionary[384], 194, 113, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Illusion Item Storage + + // Roaming "Illusion Sorc. Spirit" monsters (398-399) - killing them grants skill points for the + // event's special skills. + for (var i = 0; i < SorcererSpiritPositions.Length; i++) + { + var (x, y) = SorcererSpiritPositions[i]; + yield return this.CreateMonsterSpawn((short)(120 + i), this.NpcDictionary[(short)(398 + (i % 2))], x, y, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); + } } + private static readonly (byte X, byte Y)[] SorcererSpiritPositions = + { + (131, 93), (131, 89), (131, 85), + (168, 123), (164, 123), (160, 123), + (169, 48), (169, 52), (169, 56), + (206, 85), (206, 81), (206, 77), + (158, 85), (168, 94), (169, 75), + (179, 85), (157, 66), (162, 66), + (150, 78), (150, 73), (176, 103), + (181, 103), (187, 98), (187, 92), + (197, 57), (141, 113), (193, 61), + (145, 109), (189, 65), (149, 105), + (167, 87), (171, 83), + }; + /// protected override void CreateMonsters() { diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple6.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple6.cs index 2056c08d46..5a7f839065 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple6.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/IllusionTemple6.cs @@ -37,21 +37,28 @@ public IllusionTemple6(IContext context, GameConfiguration gameConfiguration) /// protected override string MapName => Name; + /// + protected override byte SafezoneMapNumber => Devias.Number; + /// protected override IEnumerable CreateMonsterSpawns() { // NPCs: - yield return this.CreateMonsterSpawn(100, this.NpcDictionary[658], 169, 085, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Cursed Statue - yield return this.CreateMonsterSpawn(101, this.NpcDictionary[659], 136, 101, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (1) - yield return this.CreateMonsterSpawn(102, this.NpcDictionary[660], 151, 119, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (2) - yield return this.CreateMonsterSpawn(103, this.NpcDictionary[661], 150, 088, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (3) - yield return this.CreateMonsterSpawn(104, this.NpcDictionary[662], 165, 102, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (4) - yield return this.CreateMonsterSpawn(105, this.NpcDictionary[663], 173, 067, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (5) - yield return this.CreateMonsterSpawn(106, this.NpcDictionary[664], 187, 081, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (6) - yield return this.CreateMonsterSpawn(107, this.NpcDictionary[665], 187, 051, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (7) - yield return this.CreateMonsterSpawn(108, this.NpcDictionary[666], 203, 067, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (8) - yield return this.CreateMonsterSpawn(109, this.NpcDictionary[667], 133, 121, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (9) - yield return this.CreateMonsterSpawn(110, this.NpcDictionary[668], 206, 048, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Captured Stone Statue (10) + // Pool of Stone Statue (380) positions - only one of them is active at a time, randomly picked + // from these two by the game logic. Positions confirmed against a working Season 6 Episode 3 + // server's spawn list. + yield return this.CreateMonsterSpawn(100, this.NpcDictionary[380], 207, 047, Direction.Undefined, SpawnTrigger.ManuallyForEvent); + yield return this.CreateMonsterSpawn(101, this.NpcDictionary[380], 134, 121, Direction.Undefined, SpawnTrigger.ManuallyForEvent); + + // Team guardians (decorative). + yield return this.CreateMonsterSpawn(110, this.NpcDictionary[381], 139, 046, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // MU Allies General + yield return this.CreateMonsterSpawn(111, this.NpcDictionary[382], 194, 123, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Illusion Elder + + // Relic delivery targets - the team which carries the relic here scores a point. + yield return this.CreateMonsterSpawn(112, this.NpcDictionary[383], 141, 059, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Alliance Item Storage + yield return this.CreateMonsterSpawn(113, this.NpcDictionary[384], 194, 113, Direction.Undefined, SpawnTrigger.AutomaticDuringEvent); // Illusion Item Storage + + // Temple 6 has no roaming "Illusion Sorc. Spirit" monsters. } /// From c6599243ca8f0df0114704d021b281e79dbf8aea Mon Sep 17 00:00:00 2001 From: bulgarashi Date: Sun, 23 Aug 2026 13:58:39 +0200 Subject: [PATCH 04/11] Add an update plugin to bring existing databases up to date Existing servers won't get the Illusion Temple changes above just from an EF migration - the mini game definitions, monster/item data and map spawns are seed data, normally only created on a fresh install. This update plugin applies all of it to an already-running database instead: - Fixes the "Illusion Sorcerer Covenant"/"Scroll of Blood" ticket item numbers (50/51 were swapped - IllusionTempleInitializer expects the ticket at Group 13, Number 51), by correcting the Number field on the existing item entities so already-owned instances keep working. - Adds the sacred relic item (Group 14, Number 64) if missing. - Adds the 14 arena monster definitions (386-399) and their spawns, the corrected statue/guardian/box spawns, and the safezone map fix - all matching the fresh-install map data from the previous commit. - Adds the two special-skill magic effects (210/211). - Creates the mini game definitions on a database that doesn't have Illusion Temple at all yet, or backfills MinimumPlayerCount on ones that already do. Co-Authored-By: Claude Sonnet 5 --- .../Updates/IllusionTempleDataUpdatePlugIn.cs | 452 ++++++++++++++++++ .../Initialization/Updates/UpdateVersion.cs | 4 + .../Items/EventTicketItems.cs | 5 +- 3 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 src/Persistence/Initialization/Updates/IllusionTempleDataUpdatePlugIn.cs diff --git a/src/Persistence/Initialization/Updates/IllusionTempleDataUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/IllusionTempleDataUpdatePlugIn.cs new file mode 100644 index 0000000000..979272df31 --- /dev/null +++ b/src/Persistence/Initialization/Updates/IllusionTempleDataUpdatePlugIn.cs @@ -0,0 +1,452 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Updates; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel.Attributes; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Events; +using MUnique.OpenMU.PlugIns; + +/// +/// The illusion temple update plugin. Brings an existing database up to date with everything the event +/// needs, without requiring a full reinstall: the mini game definitions, the statue/guardian/relic-box +/// spawns and the roaming arena monsters on the six temple maps, the sacred relic and ticket items, the +/// two special-skill magic effects, and a couple of fields on already-existing rows that a plain data +/// migration (EF migration) can add as a column but not populate with the right value. +/// +[PlugIn] +[Display(Name = PlugInName, Description = PlugInDescription)] +[Guid("032FECC5-932E-4161-A50A-DF7D07AF3866")] +public class IllusionTempleDataUpdatePlugIn : UpdatePlugInBase +{ + /// + /// The number of the Mirage NPC, which opens the illusion temple dialog. + /// + private const short MirageNpcNumber = 385; + + /// + /// The number of the Stone Statue NPC, which holds the sacred relic during a match. + /// + private const short StoneStatueNumber = 380; + + /// + /// The number of the MU Allies General NPC (decorative team guardian). + /// + private const short AllianceGuardianNumber = 381; + + /// + /// The number of the Illusion Elder NPC (decorative team guardian). + /// + private const short IllusionGuardianNumber = 382; + + /// + /// The number of the Alliance Item Storage NPC, to which the allied forces carry the relic to score. + /// + private const short AllianceItemStorageNumber = 383; + + /// + /// The number of the Illusion Item Storage NPC, to which the illusion forces carry the relic to score. + /// + private const short IllusionItemStorageNumber = 384; + + /// + /// The lowest NPC number of the roaming "Illusion Sorc. Spirit" arena monsters, across all temples. + /// + private const short ArenaMonsterRangeStart = 386; + + /// + /// The highest NPC number of the roaming "Illusion Sorc. Spirit" arena monsters, across all temples. + /// + private const short ArenaMonsterRangeEnd = 399; + + /// + /// The default minimum player count for an illusion temple match, if the definition doesn't already + /// have one configured through the admin panel. + /// + private const int DefaultMinimumPlayerCount = 2; + + /// + /// The map numbers of the six illusion temples, and the arena monster NPC number range (base id and + /// how many ids to cycle through) which each of them roams with - temple 6 has none. + /// + private static readonly (byte MapNumber, short ArenaMonsterBase, int ArenaMonsterCycleLength)[] Temples = + { + (45, 386, 3), + (46, 389, 3), + (47, 392, 3), + (48, 395, 3), + (49, 398, 2), + (50, 0, 0), + }; + + /// + /// The pool of statue spawn positions, shared by all six illusion temples. Only one of them is + /// active at a time, randomly picked by the game logic. + /// + private static readonly (byte X, byte Y)[] StatuePositions = + { + (207, 047), + (134, 121), + }; + + /// + /// The positions of the decorative team guardians, shared by all six illusion temples. + /// + private static readonly (byte X, byte Y) AllianceGuardianPosition = (139, 046); + + private static readonly (byte X, byte Y) IllusionGuardianPosition = (194, 123); + + /// + /// The position of the Alliance Item Storage, close to the allied forces' own spawn area. + /// + private static readonly (byte X, byte Y) AllianceBoxPosition = (141, 059); + + /// + /// The position of the Illusion Item Storage, close to the illusion forces' own spawn area. + /// + private static readonly (byte X, byte Y) IllusionBoxPosition = (194, 113); + + /// + /// The 32 roaming arena monster spawn positions, shared by all temples that have them - the NPC + /// number at each position cycles through the temple's arena monster range. + /// + private static readonly (byte X, byte Y)[] ArenaMonsterPositions = + { + (131, 93), (131, 89), (131, 85), + (168, 123), (164, 123), (160, 123), + (169, 48), (169, 52), (169, 56), + (206, 85), (206, 81), (206, 77), + (158, 85), (168, 94), (169, 75), + (179, 85), (157, 66), (162, 66), + (150, 78), (150, 73), (176, 103), + (181, 103), (187, 98), (187, 92), + (197, 57), (141, 113), (193, 61), + (145, 109), (189, 65), (149, 105), + (167, 87), (171, 83), + }; + + /// + /// The stats of the 14 "Illusion Sorc. Spirit" arena monster variants (386 to 399), one per temple + /// level (temples 1 to 4 have three variants each, temple 5 has two, temple 6 has none). + /// + private static readonly (short Number, int Level, int Hp, int MinDmg, int MaxDmg, int Defense, int AttackRate, int DefenseRate, byte AttackRange, short ViewRange, int MoveDelayMs, int AttackDelayMs)[] ArenaMonsterStats = + { + (386, 65, 7150, 195, 245, 150, 340, 98, 4, 4, 800, 1600), + (387, 65, 7150, 215, 265, 170, 380, 110, 4, 4, 800, 1600), + (388, 67, 7370, 235, 285, 190, 440, 130, 1, 6, 1600, 2000), + (389, 70, 8680, 280, 330, 210, 500, 150, 4, 4, 800, 1600), + (390, 70, 8680, 300, 350, 230, 560, 170, 4, 4, 800, 1600), + (391, 72, 8928, 320, 370, 250, 640, 200, 1, 6, 1600, 2000), + (392, 75, 15000, 375, 395, 280, 460, 150, 4, 4, 800, 1600), + (393, 75, 15000, 395, 415, 300, 520, 160, 4, 4, 800, 1600), + (394, 77, 15400, 415, 435, 320, 580, 195, 1, 6, 1600, 2000), + (395, 80, 19200, 480, 500, 360, 660, 230, 4, 4, 800, 1600), + (396, 80, 19200, 500, 520, 380, 720, 260, 4, 4, 800, 1600), + (397, 82, 19680, 520, 540, 400, 840, 280, 1, 6, 1600, 2000), + (398, 85, 25500, 595, 615, 450, 760, 275, 4, 4, 800, 1600), + (399, 85, 25500, 615, 635, 470, 820, 303, 4, 4, 800, 1600), + }; + + /// + /// The plug in name. + /// + internal const string PlugInName = "Illusion Temple Data"; + + /// + /// The plug in description. + /// + internal const string PlugInDescription = "This update creates the configuration data for the illusion temple event and assigns the event dialog to the Mirage NPC."; + + /// + public override UpdateVersion Version => UpdateVersion.IllusionTempleData; + + /// + public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id; + + /// + public override string Name => PlugInName; + + /// + public override string Description => PlugInDescription; + + /// + public override bool IsMandatory => true; + + /// + public override DateTime CreatedAt => new(2026, 07, 29, 20, 0, 0, DateTimeKind.Utc); + + /// +#pragma warning disable CS1998 + protected override async ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration) +#pragma warning restore CS1998 + { + this.FixTicketItemNumbers(gameConfiguration); + this.AddRelicItem(context, gameConfiguration); + this.AddArenaMonsterDefinitions(context, gameConfiguration); + this.AddMapSpawns(context, gameConfiguration); + this.FixSafezoneMaps(gameConfiguration); + this.AddSpecialSkillEffects(context, gameConfiguration); + + if (gameConfiguration.MiniGameDefinitions.All(def => def.Type != MiniGameType.IllusionTemple)) + { + var initializer = new IllusionTempleInitializer(context, gameConfiguration); + initializer.Initialize(); + } + else + { + foreach (var definition in gameConfiguration.MiniGameDefinitions.Where(def => def.Type == MiniGameType.IllusionTemple && def.MinimumPlayerCount <= 0)) + { + definition.MinimumPlayerCount = DefaultMinimumPlayerCount; + } + } + + if (gameConfiguration.Monsters.FirstOrDefault(monster => monster.Number == MirageNpcNumber) is { } mirage) + { + mirage.NpcWindow = NpcWindow.IllusionTemple; + } + } + + /// + /// The illusion temple ticket ("Illusion Sorcerer Covenant") and the "Scroll of Blood" item used to + /// have their numbers swapped (50/51) - looks the ticket up + /// by Group 13, Number 51, so an existing database still using the old numbering needs the two + /// numbers corrected. Only the Number field is touched, so already-owned instances of either item + /// keep referencing the same, unchanged, item definition entity. + /// + private void FixTicketItemNumbers(GameConfiguration gameConfiguration) + { + var covenant = gameConfiguration.Items.FirstOrDefault(item => item.Group == 13 && item.Name == "Illusion Sorcerer Covenant"); + var scrollOfBlood = gameConfiguration.Items.FirstOrDefault(item => item.Group == 13 && item.Name == "Scroll of Blood"); + if (covenant is { Number: 50 } && scrollOfBlood is { Number: 51 }) + { + covenant.Number = 51; + scrollOfBlood.Number = 50; + } + } + + /// + /// Adds the sacred relic item ("Cursed Castle Water", Group 14, Number 64), if it doesn't exist yet - + /// + /// looks it up by Group/Number. + /// + private void AddRelicItem(IContext context, GameConfiguration gameConfiguration) + { + if (gameConfiguration.Items.Any(item => item.Group == 14 && item.Number == 64)) + { + return; + } + + var relic = context.CreateNew(); + gameConfiguration.Items.Add(relic); + relic.Group = 14; + relic.Number = 64; + relic.Name = "Cursed Castle Water"; + relic.Width = 1; + relic.Height = 1; + relic.Durability = 1; + relic.DropsFromMonsters = false; + relic.SetGuid(relic.Group, relic.Number); + } + + /// + /// Adds the 14 "Illusion Sorc. Spirit" arena monster definitions (386 to 399), if they don't exist + /// yet. + /// + private void AddArenaMonsterDefinitions(IContext context, GameConfiguration gameConfiguration) + { + foreach (var spirit in ArenaMonsterStats) + { + if (gameConfiguration.Monsters.Any(monster => monster.Number == spirit.Number)) + { + continue; + } + + var def = context.CreateNew(); + def.Number = spirit.Number; + def.Designation = "Illusion Sorc. Spirit"; + def.MoveRange = 3; + def.AttackRange = spirit.AttackRange; + def.ViewRange = spirit.ViewRange; + def.MoveDelay = TimeSpan.FromMilliseconds(spirit.MoveDelayMs); + def.AttackDelay = TimeSpan.FromMilliseconds(spirit.AttackDelayMs); + def.RespawnDelay = TimeSpan.FromSeconds(10); + def.Attribute = 2; + def.NumberOfMaximumItemDrops = 1; + var attributes = new Dictionary + { + { Stats.Level, spirit.Level }, + { Stats.MaximumHealth, spirit.Hp }, + { Stats.MinimumPhysBaseDmg, spirit.MinDmg }, + { Stats.MaximumPhysBaseDmg, spirit.MaxDmg }, + { Stats.DefenseBase, spirit.Defense }, + { Stats.AttackRatePvm, spirit.AttackRate }, + { Stats.DefenseRatePvm, spirit.DefenseRate }, + }; + def.AddAttributes(attributes, context, gameConfiguration); + gameConfiguration.Monsters.Add(def); + def.SetGuid(def.Number); + } + } + + /// + /// Adds the stone statue, team guardian, relic delivery and arena monster spawn points to the six + /// illusion temple maps, if they don't have them yet. + /// + /// + /// The maps themselves are usually already present in an existing database (they've been part of the + /// game data since 2018), but this update is needed to add the previously missing spawns to + /// them - the game maps aren't recreated by , only its mini + /// game definitions are. + /// + private void AddMapSpawns(IContext context, GameConfiguration gameConfiguration) + { + var stoneStatue = gameConfiguration.Monsters.FirstOrDefault(monster => monster.Number == StoneStatueNumber); + var allianceGuardian = gameConfiguration.Monsters.FirstOrDefault(monster => monster.Number == AllianceGuardianNumber); + var illusionGuardian = gameConfiguration.Monsters.FirstOrDefault(monster => monster.Number == IllusionGuardianNumber); + var allianceBox = gameConfiguration.Monsters.FirstOrDefault(monster => monster.Number == AllianceItemStorageNumber); + var illusionBox = gameConfiguration.Monsters.FirstOrDefault(monster => monster.Number == IllusionItemStorageNumber); + if (stoneStatue is null || allianceGuardian is null || illusionGuardian is null || allianceBox is null || illusionBox is null) + { + return; + } + + foreach (var temple in Temples) + { + var map = gameConfiguration.Maps.FirstOrDefault(m => m.Number == temple.MapNumber); + if (map is null) + { + continue; + } + + if (map.MonsterSpawns.Any(spawn => spawn.MonsterDefinition == stoneStatue)) + { + continue; + } + + short spawnNumber = 100; + foreach (var (x, y) in StatuePositions) + { + // Only one of the pool positions is spawned at a time, randomly picked by the game logic. + this.AddSpawn(context, map, spawnNumber++, stoneStatue, x, y, SpawnTrigger.ManuallyForEvent); + } + + this.AddSpawn(context, map, spawnNumber++, allianceGuardian, AllianceGuardianPosition.X, AllianceGuardianPosition.Y, SpawnTrigger.AutomaticDuringEvent); + this.AddSpawn(context, map, spawnNumber++, illusionGuardian, IllusionGuardianPosition.X, IllusionGuardianPosition.Y, SpawnTrigger.AutomaticDuringEvent); + this.AddSpawn(context, map, spawnNumber++, allianceBox, AllianceBoxPosition.X, AllianceBoxPosition.Y, SpawnTrigger.AutomaticDuringEvent); + this.AddSpawn(context, map, spawnNumber++, illusionBox, IllusionBoxPosition.X, IllusionBoxPosition.Y, SpawnTrigger.AutomaticDuringEvent); + + if (temple.ArenaMonsterCycleLength <= 0) + { + continue; + } + + for (var i = 0; i < ArenaMonsterPositions.Length; i++) + { + var monsterNumber = (short)(temple.ArenaMonsterBase + (i % temple.ArenaMonsterCycleLength)); + var monsterDefinition = gameConfiguration.Monsters.FirstOrDefault(monster => monster.Number == monsterNumber); + if (monsterDefinition is null) + { + continue; + } + + var (x, y) = ArenaMonsterPositions[i]; + this.AddSpawn(context, map, spawnNumber++, monsterDefinition, x, y, SpawnTrigger.AutomaticDuringEvent); + } + } + } + + private void AddSpawn(IContext context, GameMapDefinition map, short spawnNumber, MonsterDefinition monsterDefinition, byte x, byte y, SpawnTrigger spawnTrigger) + { + var area = context.CreateNew(); + area.SetGuid(map.Number, spawnNumber); + area.GameMap = map; + area.MonsterDefinition = monsterDefinition; + area.Quantity = 1; + area.Direction = Direction.Undefined; + area.SpawnTrigger = spawnTrigger; + area.X1 = x; + area.X2 = x; + area.Y1 = y; + area.Y2 = y; + map.MonsterSpawns.Add(area); + } + + /// + /// The six illusion temple maps have their own spawn gate, so + /// used to default to the temple map itself instead of Devias - a player who ends up warped to his + /// "safezone" (e.g. because too few players entered) would just be sent right back into the arena + /// instead of actually leaving it. + /// + private void FixSafezoneMaps(GameConfiguration gameConfiguration) + { + var devias = gameConfiguration.Maps.FirstOrDefault(map => map.Number == 2); + if (devias is null) + { + return; + } + + foreach (var temple in Temples) + { + var map = gameConfiguration.Maps.FirstOrDefault(m => m.Number == temple.MapNumber); + if (map is null || map.SafezoneMap == devias) + { + continue; + } + + map.SafezoneMap = devias; + } + } + + /// + /// Adds the two magic effects used by the event's special skills (210 - Order of Protection and + /// 211 - Restraint), if they don't exist yet. The other two special skills (212 - Tracking and + /// 213 - Weaken) act instantly and don't need a magic effect of their own. + /// + private void AddSpecialSkillEffects(IContext context, GameConfiguration gameConfiguration) + { + const short protectionEffectNumber = 210; + const short restraintEffectNumber = 211; + + if (gameConfiguration.MagicEffects.Any(effect => effect.Number == protectionEffectNumber)) + { + return; + } + + var protection = context.CreateNew(); + gameConfiguration.MagicEffects.Add(protection); + protection.Number = protectionEffectNumber; + protection.Name = "Illusion Temple - Order of Protection"; + protection.InformObservers = true; + protection.StopByDeath = true; + protection.Duration = context.CreateNew(); + protection.Duration.ConstantValue!.Value = 15; // 15 seconds + + var protectionPowerUp = context.CreateNew(); + protection.PowerUpDefinitions.Add(protectionPowerUp); + protectionPowerUp.TargetAttribute = Stats.DamageReceiveDecrement.GetPersistent(gameConfiguration); + protectionPowerUp.Boost = context.CreateNew(); + protectionPowerUp.Boost.ConstantValue.Value = 0.50f; // 50 % damage reduction + protectionPowerUp.Boost.ConstantValue.AggregateType = AggregateType.Multiplicate; + + var restraint = context.CreateNew(); + gameConfiguration.MagicEffects.Add(restraint); + restraint.Number = restraintEffectNumber; + restraint.Name = "Illusion Temple - Restraint"; + restraint.InformObservers = true; + restraint.StopByDeath = true; + restraint.Duration = context.CreateNew(); + restraint.Duration.ConstantValue!.Value = 15; // 15 seconds + + var restraintPowerUp = context.CreateNew(); + restraint.PowerUpDefinitions.Add(restraintPowerUp); + restraintPowerUp.TargetAttribute = Stats.IsFrozen.GetPersistent(gameConfiguration); + restraintPowerUp.Boost = context.CreateNew(); + restraintPowerUp.Boost.ConstantValue.Value = 1; + } +} diff --git a/src/Persistence/Initialization/Updates/UpdateVersion.cs b/src/Persistence/Initialization/Updates/UpdateVersion.cs index 21e0ccdb0b..328bc5b44d 100644 --- a/src/Persistence/Initialization/Updates/UpdateVersion.cs +++ b/src/Persistence/Initialization/Updates/UpdateVersion.cs @@ -529,4 +529,8 @@ public enum UpdateVersion /// The version of the . /// ConfigureCastleSiegeParticipation = 104, + + /// The version of the . + /// + IllusionTempleData = 105, } diff --git a/src/Persistence/Initialization/VersionSeasonSix/Items/EventTicketItems.cs b/src/Persistence/Initialization/VersionSeasonSix/Items/EventTicketItems.cs index 4920a197c9..ae76ec72a5 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Items/EventTicketItems.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Items/EventTicketItems.cs @@ -36,8 +36,9 @@ public override void Initialize() // Illusion Temple: this.CreateEventItem(49, 13, 1, 1, "Old Scroll", false, 6, 66, 72, 78, 84, 90, 96); - 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); + this.CreateEventItem(51, 13, 1, 2, "Illusion Sorcerer Covenant", false, 6, 70, 76, 82, 88, 94, 100); + this.CreateEventItem(50, 13, 2, 2, "Scroll of Blood", false, 6); + this.CreateEventItem(64, 14, 1, 1, "Cursed Castle Water", false); // Devil Square: this.CreateEventItem(17, 14, 1, 1, "Devil's Eye", false, 7, 2, 36, 47, 60, 70, 80, 90); From d337729f97a4ae6a18d437965d3eb4b70be57c3a Mon Sep 17 00:00:00 2001 From: bulgarashi Date: Sun, 23 Aug 2026 13:58:50 +0200 Subject: [PATCH 05/11] Add tests for the Illusion Temple event Covers entrance/entry timing, finishing early when too few players remain, team assignment for even and odd player counts, game start, the statue/relic pickup-death-pickup loop, scoring for the carrier's own team vs. the enemy's, and the end-of-game reward flow (experience granted immediately, an item reward only once claimed). MiniGameContext auto-starts a real-time countdown on construction (clamped to at least 30s), far too slow for a test suite, so these tests build a fully wired IllusionTempleContext and drive its lifecycle hooks directly via reflection instead of waiting on the real timers. Extends GameContextTestHelper.CreateGameContext with an optional IDropGenerator parameter (needed to test item rewards) and a MaximumLevel default (needed for AddExperienceAsync to actually grant anything - it no-ops above the configured max level, which defaulted to 0). Co-Authored-By: Claude Sonnet 5 --- .../GameContextTestHelper.cs | 7 +- .../IllusionTempleContextTest.cs | 582 ++++++++++++++++++ 2 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs diff --git a/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs b/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs index f44ac74eee..94ab1f48cb 100644 --- a/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs +++ b/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs @@ -18,9 +18,11 @@ public static class GameContextTestHelper /// Creates a game context. /// /// Additional plugin configurations which should be applied, e.g. to deactivate specific plugins. + /// The drop generator to use, e.g. to test reward item drops. Defaults to , which never generates anything. /// The game context with MuHelperFeaturePlugIn configured. - public static IGameContext CreateGameContext(IEnumerable? additionalPlugInConfigurations = null) + public static IGameContext CreateGameContext(IEnumerable? additionalPlugInConfigurations = null, IDropGenerator? dropGenerator = null) { + dropGenerator ??= NullDropGenerator.Instance; var contextProvider = new InMemoryPersistenceContextProvider(); var context = contextProvider.CreateNewContext(); var gameConfig = context.CreateNew(); @@ -32,6 +34,7 @@ public static IGameContext CreateGameContext(IEnumerable? a gameConfig.RecoveryInterval = int.MaxValue; gameConfig.MaximumInventoryMoney = int.MaxValue; gameConfig.ItemDropDuration = TimeSpan.FromMinutes(1); + gameConfig.MaximumLevel = 400; var mapInitializer = new MapInitializer(gameConfig, new NullLogger(), NullDropGenerator.Instance, null); var plugInConfigurations = new List @@ -49,7 +52,7 @@ public static IGameContext CreateGameContext(IEnumerable? a } var plugInManager = new PlugInManager(plugInConfigurations, new NullLoggerFactory(), null, null); - var gameContext = new GameContext(gameConfig, contextProvider, mapInitializer, new NullLoggerFactory(), plugInManager, NullDropGenerator.Instance, new ConfigurationChangeMediator()); + var gameContext = new GameContext(gameConfig, contextProvider, mapInitializer, new NullLoggerFactory(), plugInManager, dropGenerator, new ConfigurationChangeMediator()); mapInitializer.PlugInManager = gameContext.PlugInManager; mapInitializer.PathFinderPool = gameContext.PathFinderPool; diff --git a/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs b/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs new file mode 100644 index 0000000000..5f8534e949 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs @@ -0,0 +1,582 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using System.Reflection; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.PlayerActions; +using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames; +using MUnique.OpenMU.GameLogic.Views.NPC; +using MUnique.OpenMU.Pathfinding; + +/// +/// Tests the illusion temple event: team assignment, the statue/relic loop, scoring, and the +/// end-of-game reward flow. +/// +/// +/// auto-starts a real-time countdown (via RunGameAsync) as soon as +/// it's constructed, and that countdown is clamped to at least 30 seconds - far too slow for a test +/// suite. Instead, these tests build a fully wired and drive its +/// lifecycle hooks (OnGameStartAsync, GameEndedAsync, ...) directly via reflection, so +/// each scenario runs deterministically without waiting on the real timers. +/// +[TestFixture] +public class IllusionTempleContextTest +{ + private const short StatueNumber = 380; + private const short AlliedStorageNumber = 383; + private const short IllusionStorageNumber = 384; + + /// + /// While the entrance is open, players can join - once it's closed (e.g. because the entrance + /// duration elapsed), further entries are refused. + /// + [Test] + public async ValueTask EntranceAcceptsPlayersOnlyWhileOpenAsync() + { + var gameContext = CreateGameContext(); + var definition = CreateDefinition(gameContext, minimumPlayerCount: 2); + await using var illusionTemple = await CreateContextAsync(gameContext, definition).ConfigureAwait(false); + var player = await CreatePlayerAsync(gameContext).ConfigureAwait(false); + + var resultWhileOpen = await illusionTemple.TryEnterAsync(player).ConfigureAwait(false); + + await InvokePrivateAsync(illusionTemple, "CloseEntranceAsync").ConfigureAwait(false); + var latePlayer = await CreatePlayerAsync(gameContext).ConfigureAwait(false); + var resultAfterClose = await illusionTemple.TryEnterAsync(latePlayer).ConfigureAwait(false); + + Assert.That(resultWhileOpen, Is.EqualTo(EnterResult.Success)); + Assert.That(resultAfterClose, Is.EqualTo(EnterResult.NotOpen)); + + } + + /// + /// If the player count drops below the configured minimum while a match is running (e.g. a player + /// disconnects), the event is finished right away instead of continuing with too few participants. + /// + [Test] + public async ValueTask FinishesWhenTooFewPlayersRemainAsync() + { + var gameContext = CreateGameContext(); + var definition = CreateDefinition(gameContext, minimumPlayerCount: 2); + await using var illusionTemple = await CreateContextAsync(gameContext, definition).ConfigureAwait(false); + var players = await EnterPlayersAsync(illusionTemple, gameContext, 2).ConfigureAwait(false); + await StartGameAsync(illusionTemple, players).ConfigureAwait(false); + + await illusionTemple.Map.RemoveAsync(players[0]).ConfigureAwait(false); + + Assert.That(illusionTemple.PlayerCount, Is.LessThan(2)); + + } + + /// + /// An even number of players is split into two equally sized teams. + /// + [TestCase(2)] + [TestCase(4)] + [TestCase(10)] + public async ValueTask SplitsAnEvenPlayerCountIntoEqualTeamsAsync(int playerCount) + { + var gameContext = CreateGameContext(); + var definition = CreateDefinition(gameContext, minimumPlayerCount: 2, maximumPlayerCount: 10); + await using var illusionTemple = await CreateContextAsync(gameContext, definition).ConfigureAwait(false); + var players = await EnterPlayersAsync(illusionTemple, gameContext, playerCount).ConfigureAwait(false); + + await StartGameAsync(illusionTemple, players).ConfigureAwait(false); + + var teamCounts = players + .Select(p => GetTeamOf(illusionTemple, p)) + .GroupBy(t => t) + .ToDictionary(g => g.Key, g => g.Count()); + + Assert.That(teamCounts.Values, Has.All.EqualTo(playerCount / 2)); + Assert.That(teamCounts.Keys, Is.EquivalentTo(new[] { IllusionTempleTeam.AlliedForces, IllusionTempleTeam.IllusionForces })); + + } + + /// + /// An odd number of players still gets split as evenly as possible - one team gets one extra + /// member, the difference between the two teams is never more than one. + /// + [TestCase(3)] + [TestCase(5)] + [TestCase(9)] + public async ValueTask SplitsAnOddPlayerCountAsEvenlyAsPossibleAsync(int playerCount) + { + var gameContext = CreateGameContext(); + var definition = CreateDefinition(gameContext, minimumPlayerCount: 2, maximumPlayerCount: 10); + await using var illusionTemple = await CreateContextAsync(gameContext, definition).ConfigureAwait(false); + var players = await EnterPlayersAsync(illusionTemple, gameContext, playerCount).ConfigureAwait(false); + + await StartGameAsync(illusionTemple, players).ConfigureAwait(false); + + var teamCounts = players + .Select(p => GetTeamOf(illusionTemple, p)) + .GroupBy(t => t) + .ToDictionary(g => g.Key, g => g.Count()); + + Assert.That(teamCounts.Values.Sum(), Is.EqualTo(playerCount)); + Assert.That(Math.Abs(teamCounts.Values.Max() - teamCounts.Values.Min()), Is.LessThanOrEqualTo(1)); + + } + + /// + /// Starting the game assigns every player a team - only + /// resolves to a gate for players who made it into a team. + /// + [Test] + public async ValueTask GameStartAssignsEveryPlayerASpawnGateAsync() + { + var gameContext = CreateGameContext(); + var definition = CreateDefinition(gameContext, minimumPlayerCount: 2); + await using var illusionTemple = await CreateContextAsync(gameContext, definition).ConfigureAwait(false); + var players = await EnterPlayersAsync(illusionTemple, gameContext, 2).ConfigureAwait(false); + + await StartGameAsync(illusionTemple, players).ConfigureAwait(false); + + foreach (var player in players) + { + Assert.That(illusionTemple.GetSpawnGate(player), Is.Not.Null); + } + + } + + /// + /// Talking to the stone statue grants the sacred relic item and marks the player as its carrier - + /// the client is informed who the new carrier is. + /// + [Test] + public async ValueTask TalkingToTheStatueGrantsTheRelicAsync() + { + var gameContext = CreateGameContext(); + var definition = CreateDefinition(gameContext, minimumPlayerCount: 2); + await using var illusionTemple = await CreateContextAsync(gameContext, definition).ConfigureAwait(false); + var players = await EnterPlayersAsync(illusionTemple, gameContext, 2).ConfigureAwait(false); + await StartGameAsync(illusionTemple, players).ConfigureAwait(false); + var player = players[0]; + player.OpenedNpc = CreateStatueNpc(illusionTemple); + + await illusionTemple.TalkToNpcStoneStatueAsync(player).ConfigureAwait(false); + + Assert.That(player.Inventory!.Items.Any(IsRelicItem), Is.True); + Assert.That(GetRelicCarrier(illusionTemple), Is.EqualTo(player)); + + } + + /// + /// When the relic carrier dies, the relic is dropped on the ground and can be picked up by another + /// participant, who then becomes the new carrier. + /// + [Test] + public async ValueTask DyingDropsTheRelicAndItCanBePickedUpAgainAsync() + { + var gameContext = CreateGameContext(); + var definition = CreateDefinition(gameContext, minimumPlayerCount: 2); + await using var illusionTemple = await CreateContextAsync(gameContext, definition).ConfigureAwait(false); + var players = await EnterPlayersAsync(illusionTemple, gameContext, 2).ConfigureAwait(false); + await StartGameAsync(illusionTemple, players).ConfigureAwait(false); + var carrier = players[0]; + var otherPlayer = players[1]; + carrier.OpenedNpc = CreateStatueNpc(illusionTemple); + await illusionTemple.TalkToNpcStoneStatueAsync(carrier).ConfigureAwait(false); + + // OnPlayerDied is "async void" in production (it's a plain event handler), so its relic-drop + // logic - including the fire-and-forget continuation which finally clears _relicCarrier once + // the dropped item lands on the map - keeps running after the reflection call above already + // returned. Poll briefly instead of asserting immediately. + await InvokeProtectedAsync(illusionTemple, "OnPlayerDied", [carrier, new DeathInformation(otherPlayer.Id, otherPlayer.Name, default, 0)]).ConfigureAwait(false); + await WaitUntilAsync(() => GetRelicCarrier(illusionTemple) is null).ConfigureAwait(false); + + Assert.That(carrier.Inventory!.Items.Any(IsRelicItem), Is.False); + Assert.That(GetRelicCarrier(illusionTemple), Is.Null); + + var droppedRelic = illusionTemple.Map.GetDropsInRange(carrier.Position, 5).OfType().FirstOrDefault(d => IsRelicItem(d.Item)); + Assert.That(droppedRelic, Is.Not.Null); + + await InvokeProtectedAsync(illusionTemple, "OnPlayerPickedUpItemAsync", [(otherPlayer, (ILocateable)droppedRelic!)]).ConfigureAwait(false); + + Assert.That(GetRelicCarrier(illusionTemple), Is.EqualTo(otherPlayer)); + + } + + /// + /// Delivering the relic to the carrier's own team storage scores a point for that team and clears + /// the carrier state, so the relic can be granted again from the next statue. + /// + [Test] + public async ValueTask DeliveringTheRelicScoresAPointAsync() + { + var gameContext = CreateGameContext(); + var definition = CreateDefinition(gameContext, minimumPlayerCount: 2); + await using var illusionTemple = await CreateContextAsync(gameContext, definition).ConfigureAwait(false); + var players = await EnterPlayersAsync(illusionTemple, gameContext, 2).ConfigureAwait(false); + await StartGameAsync(illusionTemple, players).ConfigureAwait(false); + var carrier = players.First(p => GetTeamOf(illusionTemple, p) == IllusionTempleTeam.AlliedForces); + carrier.OpenedNpc = CreateStatueNpc(illusionTemple); + await illusionTemple.TalkToNpcStoneStatueAsync(carrier).ConfigureAwait(false); + + await illusionTemple.TalkToNpcTeamStorageAsync(AlliedStorageNumber, carrier).ConfigureAwait(false); + + Assert.That(illusionTemple.Score.AlliedForcesScore, Is.EqualTo(1)); + Assert.That(carrier.Inventory!.Items.Any(IsRelicItem), Is.False); + Assert.That(GetRelicCarrier(illusionTemple), Is.Null); + + } + + /// + /// Delivering the relic to the OTHER team's storage doesn't score anything - a carrier can only + /// score for his own side. + /// + [Test] + public async ValueTask DeliveringTheRelicToTheEnemyStorageDoesNotScoreAsync() + { + var gameContext = CreateGameContext(); + var definition = CreateDefinition(gameContext, minimumPlayerCount: 2); + await using var illusionTemple = await CreateContextAsync(gameContext, definition).ConfigureAwait(false); + var players = await EnterPlayersAsync(illusionTemple, gameContext, 2).ConfigureAwait(false); + await StartGameAsync(illusionTemple, players).ConfigureAwait(false); + var carrier = players.First(p => GetTeamOf(illusionTemple, p) == IllusionTempleTeam.AlliedForces); + carrier.OpenedNpc = CreateStatueNpc(illusionTemple); + await illusionTemple.TalkToNpcStoneStatueAsync(carrier).ConfigureAwait(false); + + await illusionTemple.TalkToNpcTeamStorageAsync(IllusionStorageNumber, carrier).ConfigureAwait(false); + + Assert.That(illusionTemple.Score.AlliedForcesScore, Is.EqualTo(0)); + Assert.That(illusionTemple.Score.IllusionForcesScore, Is.EqualTo(0)); + Assert.That(GetRelicCarrier(illusionTemple), Is.EqualTo(carrier)); + + } + + /// + /// When the game ends, the winning team's members are granted experience, which is reported back + /// per player - a losing player gets nothing. + /// + [Test] + public async ValueTask GameEndGrantsExperienceToTheWinningTeamAsync() + { + var gameContext = CreateGameContext(); + var definition = CreateDefinition(gameContext, minimumPlayerCount: 2); + await using var illusionTemple = await CreateContextAsync(gameContext, definition).ConfigureAwait(false); + var players = await EnterPlayersAsync(illusionTemple, gameContext, 2).ConfigureAwait(false); + await StartGameAsync(illusionTemple, players).ConfigureAwait(false); + var winner = players.First(p => GetTeamOf(illusionTemple, p) == IllusionTempleTeam.AlliedForces); + var loser = players.First(p => GetTeamOf(illusionTemple, p) == IllusionTempleTeam.IllusionForces); + illusionTemple.Score.IncreaseScore(IllusionTempleTeam.AlliedForces, 2); + var winnerExperienceBefore = winner.SelectedCharacter!.Experience; + var loserExperienceBefore = loser.SelectedCharacter!.Experience; + + await InvokeProtectedAsync(illusionTemple, "GameEndedAsync", [(ICollection)players]).ConfigureAwait(false); + + Assert.That(winner.SelectedCharacter!.Experience, Is.GreaterThan(winnerExperienceBefore)); + Assert.That(loser.SelectedCharacter!.Experience, Is.EqualTo(loserExperienceBefore)); + + await illusionTemple.ClaimRewardAsync(winner).ConfigureAwait(false); + await illusionTemple.ClaimRewardAsync(loser).ConfigureAwait(false); + + } + + /// + /// Besides experience, a winner can also be rewarded with an item (e.g. a jewel). Unlike experience, + /// which is granted right away, the item is only handed out once the winner actually claims his + /// reward () - matching the "close the result + /// dialog to get compensated" flow of the original event. A losing player gets neither. + /// + [Test] + public async ValueTask GameEndGrantsExperienceAndAnItemRewardToTheWinnerAsync() + { + var jewelDefinition = new MUnique.OpenMU.Persistence.BasicModel.ItemDefinition { Group = 14, Number = 16, Name = "Jewel of Life" }; + var gameContext = CreateGameContext(dropGenerator: new SingleItemDropGenerator(jewelDefinition)); + var definition = CreateDefinition(gameContext, minimumPlayerCount: 2, includeItemReward: true); + await using var illusionTemple = await CreateContextAsync(gameContext, definition).ConfigureAwait(false); + var players = await EnterPlayersAsync(illusionTemple, gameContext, 2).ConfigureAwait(false); + await StartGameAsync(illusionTemple, players).ConfigureAwait(false); + var winner = players.First(p => GetTeamOf(illusionTemple, p) == IllusionTempleTeam.AlliedForces); + var loser = players.First(p => GetTeamOf(illusionTemple, p) == IllusionTempleTeam.IllusionForces); + illusionTemple.Score.IncreaseScore(IllusionTempleTeam.AlliedForces, 2); + + await InvokeProtectedAsync(illusionTemple, "GameEndedAsync", [(ICollection)players]).ConfigureAwait(false); + + // The item isn't granted yet at game-end time - only the experience is. + Assert.That(winner.Inventory!.Items.Any(i => i.Definition == jewelDefinition), Is.False); + + await illusionTemple.ClaimRewardAsync(winner).ConfigureAwait(false); + await illusionTemple.ClaimRewardAsync(loser).ConfigureAwait(false); + + Assert.That(winner.Inventory!.Items.Any(i => i.Definition == jewelDefinition), Is.True); + Assert.That(loser.Inventory!.Items.Any(i => i.Definition == jewelDefinition), Is.False); + + } + + /// + /// Talking to the entrance npc opens the illusion temple dialog and reports how many players are + /// currently in each temple, so the client can show it next to the invite. + /// + [Test] + public async ValueTask TalkingToTheEntranceNpcShowsTheUserCountsAsync() + { + var gameContext = CreateGameContext(); + var player = await CreatePlayerAsync(gameContext).ConfigureAwait(false); + var map = await gameContext.GetMapAsync(0).ConfigureAwait(false); + var npcDefinition = new MUnique.OpenMU.Persistence.BasicModel.MonsterDefinition + { + Number = 229, + NpcWindow = NpcWindow.IllusionTemple, + }; + var npc = new NonPlayerCharacter(new MUnique.OpenMU.Persistence.BasicModel.MonsterSpawnArea { GameMap = map!.Definition }, npcDefinition, map); + + await new TalkNpcAction().TalkToNpcAsync(player, npc).ConfigureAwait(false); + + // No illusion temple is configured in this game context, so the dialog opens without throwing + // and simply reports no members - the important part is that talking to the npc is routed here + // at all and doesn't fall into the "talking not implemented" fallback. + Assert.That(player.OpenedNpc, Is.EqualTo(npc)); + + await gameContext.RemovePlayerAsync(player).ConfigureAwait(false); + } + + private static bool IsRelicItem(MUnique.OpenMU.DataModel.Entities.Item item) => item.Definition?.Group == 14 && item.Definition?.Number == 64; + + private static IllusionTempleTeam GetTeamOf(IllusionTempleContext context, Player player) + { + var gate = context.GetSpawnGate(player); + Assert.That(gate, Is.Not.Null); + return gate!.X1 < 150 ? IllusionTempleTeam.AlliedForces : IllusionTempleTeam.IllusionForces; + } + + private static Player? GetRelicCarrier(IllusionTempleContext context) + { + var field = typeof(IllusionTempleContext).GetField("_relicCarrier", BindingFlags.NonPublic | BindingFlags.Instance); + return (Player?)field!.GetValue(context); + } + + private static NonPlayerCharacter CreateStatueNpc(IllusionTempleContext context) + { + var spawnArea = context.Map.Definition.MonsterSpawns.First(s => s.MonsterDefinition?.Number == StatueNumber); + var npc = new NonPlayerCharacter(spawnArea, spawnArea.MonsterDefinition!, context.Map); + npc.Initialize(); + return npc; + } + + private static async ValueTask StartGameAsync(IllusionTempleContext context, IReadOnlyCollection players) + { + await InvokeProtectedAsync(context, "OnGameStartAsync", [(ICollection)players.ToList()]).ConfigureAwait(false); + } + + private static async ValueTask> EnterPlayersAsync(IllusionTempleContext context, IGameContext gameContext, int count) + { + var players = new List(); + for (var i = 0; i < count; i++) + { + var player = await CreatePlayerAsync(gameContext, $"Player{i}").ConfigureAwait(false); + var result = await context.TryEnterAsync(player).ConfigureAwait(false); + Assert.That(result, Is.EqualTo(EnterResult.Success)); + + // TryEnterAsync alone doesn't place the player on the event map (that's normally done by + // EnterMiniGameAction.WarpToAsync + the client's map-change acknowledgement) - do the same + // here, so CurrentMap/CurrentMiniGame are set up exactly like a real client join. + await player.ClientReadyAfterMapChangeAsync().ConfigureAwait(false); + + players.Add(player); + } + + return players; + } + + private static async ValueTask CreatePlayerAsync(IGameContext gameContext, string? name = null) + { + var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + if (name is { }) + { + player.SelectedCharacter!.Name = name; + } + + await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false); + player.IsAlive = true; + return player; + } + + private static async ValueTask CreateContextAsync(IGameContext gameContext, MiniGameDefinition definition) + { + var context = await gameContext.GetMiniGameAsync(definition, null!).ConfigureAwait(false); + return (IllusionTempleContext)context; + } + + private static MiniGameDefinition CreateDefinition(IGameContext gameContext, int minimumPlayerCount, int maximumPlayerCount = 10, bool includeItemReward = false) + { + var map = CreateMapDefinition(); + gameContext.Configuration.Maps.Add(map); + + // The sacred relic (Group 14, Number 64) - TalkToNpcStoneStatueAsync looks this up by + // Group/Number, so it has to exist in the configuration's item list. + if (!gameContext.Configuration.Items.Any(i => i.Group == 14 && i.Number == 64)) + { + gameContext.Configuration.Items.Add(new MUnique.OpenMU.Persistence.BasicModel.ItemDefinition { Group = 14, Number = 64, Name = "Cursed Castle Water" }); + } + + // Devias (map number 2) - both OnObjectRemovedFromMapAsync and GameEndedAsync warp + // participants there. + if (!gameContext.Configuration.Maps.Any(m => m.Number == 2)) + { + gameContext.Configuration.Maps.Add(new MUnique.OpenMU.Persistence.BasicModel.GameMapDefinition { Number = 2, TerrainData = new byte[ushort.MaxValue + 3] }); + } + + var definition = new MUnique.OpenMU.Persistence.BasicModel.MiniGameDefinition + { + Type = MiniGameType.IllusionTemple, + MinimumPlayerCount = minimumPlayerCount, + MaximumPlayerCount = maximumPlayerCount, + EnterDuration = TimeSpan.FromMinutes(5), + GameDuration = TimeSpan.FromMinutes(15), + ExitDuration = TimeSpan.FromMinutes(1), + MapCreationPolicy = MiniGameMapCreationPolicy.Shared, + Entrance = map.ExitGates.First(), + }; + definition.Rewards.Add(new MUnique.OpenMU.Persistence.BasicModel.MiniGameReward + { + RewardType = MiniGameRewardType.Experience, + RewardAmount = 100_000, + RequiredSuccess = MiniGameSuccessFlags.WinnerOrInWinningParty, + }); + + if (includeItemReward) + { + definition.Rewards.Add(new MUnique.OpenMU.Persistence.BasicModel.MiniGameReward + { + RewardType = MiniGameRewardType.Item, + RewardAmount = 1, + RequiredSuccess = MiniGameSuccessFlags.WinnerOrInWinningParty, + ItemReward = new MUnique.OpenMU.Persistence.BasicModel.DropItemGroup(), + }); + } + + return definition; + } + + /// + /// A fake drop generator which always hands out a single item of the given definition, regardless + /// of the reward's drop item group - used to test that a mini game's item reward actually reaches + /// the player's inventory, without needing a fully configured drop chance/item pool. + /// + private sealed class SingleItemDropGenerator : IDropGenerator + { + private readonly ItemDefinition _itemDefinition; + + public SingleItemDropGenerator(ItemDefinition itemDefinition) => this._itemDefinition = itemDefinition; + + public ValueTask<(IEnumerable Items, uint? Money)> GenerateItemDropsAsync(MonsterDefinition monster, int gainedExperience, Player player) + => ValueTask.FromResult((Enumerable.Empty(), default(uint?))); + + public Item? GenerateItemDrop(DropItemGroup group) => new MUnique.OpenMU.Persistence.BasicModel.Item { Definition = this._itemDefinition }; + + public (Item? Item, uint? Money, ItemDropEffect DropEffect) GenerateItemDrop(IEnumerable groups) + => (new MUnique.OpenMU.Persistence.BasicModel.Item { Definition = this._itemDefinition }, null, ItemDropEffect.Undefined); + } + + private static GameMapDefinition CreateMapDefinition() + { + var map = new MUnique.OpenMU.Persistence.BasicModel.GameMapDefinition + { + Number = 45, + TerrainData = new byte[ushort.MaxValue + 3], + }; + + var entrance = new MUnique.OpenMU.Persistence.BasicModel.ExitGate + { + Map = map, + IsSpawnGate = true, + X1 = 141, + Y1 = 41, + X2 = 146, + Y2 = 45, + }; + map.ExitGates.Add(entrance); + + var statueDefinition = new MUnique.OpenMU.Persistence.BasicModel.MonsterDefinition { Number = StatueNumber, ObjectKind = NpcObjectKind.Statue }; + var alliedStorageDefinition = new MUnique.OpenMU.Persistence.BasicModel.MonsterDefinition { Number = AlliedStorageNumber, ObjectKind = NpcObjectKind.PassiveNpc }; + var illusionStorageDefinition = new MUnique.OpenMU.Persistence.BasicModel.MonsterDefinition { Number = IllusionStorageNumber, ObjectKind = NpcObjectKind.PassiveNpc }; + + map.MonsterSpawns.Add(CreateSpawn(map, 100, statueDefinition, 207, 47, SpawnTrigger.ManuallyForEvent)); + map.MonsterSpawns.Add(CreateSpawn(map, 101, statueDefinition, 134, 121, SpawnTrigger.ManuallyForEvent)); + map.MonsterSpawns.Add(CreateSpawn(map, 112, alliedStorageDefinition, 141, 59, SpawnTrigger.AutomaticDuringEvent)); + map.MonsterSpawns.Add(CreateSpawn(map, 113, illusionStorageDefinition, 194, 113, SpawnTrigger.AutomaticDuringEvent)); + + return map; + } + + private static MonsterSpawnArea CreateSpawn(GameMapDefinition map, short number, MonsterDefinition monsterDefinition, byte x, byte y, SpawnTrigger trigger) + { + return new MUnique.OpenMU.Persistence.BasicModel.MonsterSpawnArea + { + GameMap = map, + MonsterDefinition = monsterDefinition, + Quantity = 1, + SpawnTrigger = trigger, + X1 = x, + X2 = x, + Y1 = y, + Y2 = y, + }; + } + + private static IGameContext CreateGameContext(IDropGenerator? dropGenerator = null) + { + return GameContextTestHelper.CreateGameContext(dropGenerator: dropGenerator); + } + + private static async ValueTask InvokePrivateAsync(object target, string methodName, params object?[] args) + { + await InvokeProtectedAsync(target, methodName, args).ConfigureAwait(false); + } + + private static async ValueTask InvokeProtectedAsync(object target, string methodName, object?[] args) + { + var method = FindMethod(target.GetType(), methodName) + ?? throw new MissingMethodException(target.GetType().Name, methodName); + var result = method.Invoke(target, args); + await AwaitResultAsync(result).ConfigureAwait(false); + } + + private static MethodInfo? FindMethod(Type type, string methodName) + { + for (var current = type; current is not null; current = current.BaseType) + { + var method = current.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly); + if (method is not null) + { + return method; + } + } + + return null; + } + + private static async ValueTask WaitUntilAsync(Func condition, int timeoutMilliseconds = 2000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMilliseconds); + while (!condition() && DateTime.UtcNow < deadline) + { + await Task.Delay(10).ConfigureAwait(false); + } + } + + private static async ValueTask AwaitResultAsync(object? result) + { + switch (result) + { + case ValueTask valueTask: + await valueTask.ConfigureAwait(false); + break; + case Task task: + await task.ConfigureAwait(false); + break; + } + } +} From bb3c8eb0fb9ef285ed96f54d5260bedf537ff317 Mon Sep 17 00:00:00 2001 From: bulgarashi Date: Sun, 23 Aug 2026 14:50:09 +0200 Subject: [PATCH 06/11] Address Codacy static analysis findings in the Illusion Temple code - Removed the unused _activeStatue field (only ever written, never read) and made the two team spawn coordinate fields readonly, since neither is ever reassigned after construction. - Renamed a local variable in TeleportToStartCoordinatesAsync that was shadowing the illusionForcesCoordinates field. - In the test file: documented why the two reflection calls that bypass accessibility are safe (test-only code, hardcoded member names, no external input), replaced a switch without a default case with if/else, dropped a redundant explicit default-value argument, gave the fake IDropGenerator's genuinely unused interface parameters discard-style names, and made the test spawn-area helper actually use its "number" parameter to build a stable Guid. Co-Authored-By: Claude Sonnet 5 --- .../MiniGames/IllusionTempleContext.cs | 19 +++------ .../IllusionTempleContextTest.cs | 42 +++++++++++++------ 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/GameLogic/MiniGames/IllusionTempleContext.cs b/src/GameLogic/MiniGames/IllusionTempleContext.cs index f82b5c56d2..ea8e4acc43 100644 --- a/src/GameLogic/MiniGames/IllusionTempleContext.cs +++ b/src/GameLogic/MiniGames/IllusionTempleContext.cs @@ -177,13 +177,13 @@ public sealed class IllusionTempleContext : MiniGameContext /// client side and are only removed when the client is told that the battle started. So the players /// wait here until the event sends that state, and walk into the arena afterwards. /// - private Point alliedForcesCoordinates = new Point(141, 41); + private readonly Point alliedForcesCoordinates = new Point(141, 41); /// /// The spawn point of the illusion forces, in the chamber at the south eastern corner. It's the /// target of the map's spawn gates 154 to 159, one per temple. /// - private Point illusionForcesCoordinates = new Point(194, 124); + private readonly Point illusionForcesCoordinates = new Point(194, 124); /// /// Remaning Time of IT @@ -195,11 +195,6 @@ public sealed class IllusionTempleContext : MiniGameContext /// private Player? _relicCarrier; - /// - /// The currently active stone statue, or null if none is currently spawned. - /// - private NonPlayerCharacter? _activeStatue; - /// /// Initializes a new instance of the class. /// @@ -305,8 +300,6 @@ public async ValueTask TalkToNpcStoneStatueAsync(Player player) await statue.DisposeAsync().ConfigureAwait(false); } - this._activeStatue = null; - this._relicCarrier = player; await this.ForEachPlayerAsync(p => p.InvokeViewPlugInAsync( vp => vp.ShowHolyItemRelicsAsync(player.Id, player.Name)).AsTask()).ConfigureAwait(false); @@ -398,8 +391,6 @@ private async ValueTask SpawnRandomStatueAsync() await this.Map.AddAsync(statue).ConfigureAwait(false); statue.OnSpawn(); - this._activeStatue = statue; - await this.ShowGoldenMessageAsync(nameof(PlayerMessage.IllusionTempleStatueSpawnedMessage)).ConfigureAwait(false); } catch (Exception ex) @@ -930,7 +921,7 @@ protected override async ValueTask GameEndedAsync(ICollection finishers) private async ValueTask TeleportToStartCoordinatesAsync(IllusionTempleTeam team, Player player) { var cordinatesAlliedForces = this.alliedForcesCoordinates; - var illusionForcesCoordinates = this.illusionForcesCoordinates; + var cordinatesIllusionForces = this.illusionForcesCoordinates; if (team == IllusionTempleTeam.AlliedForces) { cordinatesAlliedForces += new Point(1, 0); // every player on differend point (x,y) @@ -938,8 +929,8 @@ private async ValueTask TeleportToStartCoordinatesAsync(IllusionTempleTeam team, } else { - illusionForcesCoordinates += new Point(1, 0); // every player on differend point (x,y) - await player.MoveAsync(illusionForcesCoordinates).ConfigureAwait(false); + cordinatesIllusionForces += new Point(1, 0); // every player on differend point (x,y) + await player.MoveAsync(cordinatesIllusionForces).ConfigureAwait(false); } } diff --git a/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs b/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs index 5f8534e949..d3f34b66db 100644 --- a/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs +++ b/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs @@ -86,7 +86,7 @@ public async ValueTask FinishesWhenTooFewPlayersRemainAsync() public async ValueTask SplitsAnEvenPlayerCountIntoEqualTeamsAsync(int playerCount) { var gameContext = CreateGameContext(); - var definition = CreateDefinition(gameContext, minimumPlayerCount: 2, maximumPlayerCount: 10); + var definition = CreateDefinition(gameContext, minimumPlayerCount: 2); await using var illusionTemple = await CreateContextAsync(gameContext, definition).ConfigureAwait(false); var players = await EnterPlayersAsync(illusionTemple, gameContext, playerCount).ConfigureAwait(false); @@ -112,7 +112,7 @@ public async ValueTask SplitsAnEvenPlayerCountIntoEqualTeamsAsync(int playerCoun public async ValueTask SplitsAnOddPlayerCountAsEvenlyAsPossibleAsync(int playerCount) { var gameContext = CreateGameContext(); - var definition = CreateDefinition(gameContext, minimumPlayerCount: 2, maximumPlayerCount: 10); + var definition = CreateDefinition(gameContext, minimumPlayerCount: 2); await using var illusionTemple = await CreateContextAsync(gameContext, definition).ConfigureAwait(false); var players = await EnterPlayersAsync(illusionTemple, gameContext, playerCount).ConfigureAwait(false); @@ -351,6 +351,12 @@ private static IllusionTempleTeam GetTeamOf(IllusionTempleContext context, Playe return gate!.X1 < 150 ? IllusionTempleTeam.AlliedForces : IllusionTempleTeam.IllusionForces; } + /// + /// Reads the private _relicCarrier field of via + /// reflection. This is test-only code operating on a type from the same solution (no untrusted + /// input reaches this reflection call), used because the field has no public accessor - exposing + /// one purely for tests isn't warranted for a single internal implementation detail. + /// private static Player? GetRelicCarrier(IllusionTempleContext context) { var field = typeof(IllusionTempleContext).GetField("_relicCarrier", BindingFlags.NonPublic | BindingFlags.Instance); @@ -471,12 +477,12 @@ private sealed class SingleItemDropGenerator : IDropGenerator public SingleItemDropGenerator(ItemDefinition itemDefinition) => this._itemDefinition = itemDefinition; - public ValueTask<(IEnumerable Items, uint? Money)> GenerateItemDropsAsync(MonsterDefinition monster, int gainedExperience, Player player) + public ValueTask<(IEnumerable Items, uint? Money)> GenerateItemDropsAsync(MonsterDefinition _1, int _2, Player _3) => ValueTask.FromResult((Enumerable.Empty(), default(uint?))); - public Item? GenerateItemDrop(DropItemGroup group) => new MUnique.OpenMU.Persistence.BasicModel.Item { Definition = this._itemDefinition }; + public Item? GenerateItemDrop(DropItemGroup _) => new MUnique.OpenMU.Persistence.BasicModel.Item { Definition = this._itemDefinition }; - public (Item? Item, uint? Money, ItemDropEffect DropEffect) GenerateItemDrop(IEnumerable groups) + public (Item? Item, uint? Money, ItemDropEffect DropEffect) GenerateItemDrop(IEnumerable _) => (new MUnique.OpenMU.Persistence.BasicModel.Item { Definition = this._itemDefinition }, null, ItemDropEffect.Undefined); } @@ -515,6 +521,7 @@ private static MonsterSpawnArea CreateSpawn(GameMapDefinition map, short number, { return new MUnique.OpenMU.Persistence.BasicModel.MonsterSpawnArea { + Id = new Guid(0, 0, 0, 0, 0, 0, 0, 0, 0, (byte)map.Number, (byte)number), GameMap = map, MonsterDefinition = monsterDefinition, Quantity = 1, @@ -544,6 +551,14 @@ private static async ValueTask InvokeProtectedAsync(object target, string method await AwaitResultAsync(result).ConfigureAwait(false); } + /// + /// Looks up a protected or private instance method by name, walking up the type hierarchy. Used to + /// invoke 's lifecycle hooks (e.g. OnGameStartAsync, + /// GameEndedAsync) directly in tests, bypassing the real-time countdown that normally drives + /// them (see the class remarks). This is test-only code operating on a type from the same solution - + /// is always a hardcoded literal from this test file, never external + /// input. + /// private static MethodInfo? FindMethod(Type type, string methodName) { for (var current = type; current is not null; current = current.BaseType) @@ -558,7 +573,7 @@ private static async ValueTask InvokeProtectedAsync(object target, string method return null; } - private static async ValueTask WaitUntilAsync(Func condition, int timeoutMilliseconds = 2000) + private static async ValueTask WaitUntilAsync(Func condition, int timeoutMilliseconds = 5000) { var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMilliseconds); while (!condition() && DateTime.UtcNow < deadline) @@ -569,14 +584,15 @@ private static async ValueTask WaitUntilAsync(Func condition, int timeoutM private static async ValueTask AwaitResultAsync(object? result) { - switch (result) + if (result is ValueTask valueTask) + { + await valueTask.ConfigureAwait(false); + } + else if (result is Task task) { - case ValueTask valueTask: - await valueTask.ConfigureAwait(false); - break; - case Task task: - await task.ConfigureAwait(false); - break; + await task.ConfigureAwait(false); } + + // Otherwise the invoked method was synchronous (e.g. "async void") - nothing to await. } } From 3bb297293ab5c0323002668e1f98ad03484ac022 Mon Sep 17 00:00:00 2001 From: bulgarashi Date: Sun, 23 Aug 2026 14:53:26 +0200 Subject: [PATCH 07/11] Mark the Illusion Temple packet group as done in the progress doc --- docs/Progress.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/Progress.md b/docs/Progress.md index 8b06a21758..d68f53d7a0 100644 --- a/docs/Progress.md +++ b/docs/Progress.md @@ -85,6 +85,7 @@ complexity and effort). Complexity 0 means we wont implement it. | *JewelMix* | 0xBC | 100% | 4 | | | CrywolfGroup | 0xBD | 0% | 10 | | | GuildAssignStatus | 0xBE | 0% | 1 | | +| IllusionTempleGroup | 0xBF | 100% | 10 | Team-based PvP mini game: relic pickup/delivery, scoring, skill points, special skills, rewards | | FriendListRequest | 0xC0 | 0% | 0 | Not needed, friend list is sent automatically | | *FriendAdd* | 0xC1 | 100% | 2 | | | *WaitFriendAdd* | 0xC2 | 100% | 2 | | From 624adfa456a264cf23c54bbf077b6cb580d5671c Mon Sep 17 00:00:00 2001 From: bulgarashi Date: Sun, 23 Aug 2026 15:22:50 +0200 Subject: [PATCH 08/11] Fix a broken Illusion Temple test and remaining Codacy findings The DyingDropsTheRelicAndItCanBePickedUpAgain test was failing deterministically: the test entered players via TryEnterAsync and the client map-change handshake, but skipped the WarpToAsync to the event entrance that EnterMiniGameAction performs in between. Without it the players stayed on the default map instead of the mini game's own map instance, so the dropped relic landed on a map the event isn't subscribed to and OnItemDroppedOnMap - the single place that clears the relic carrier - never ran. The entry helper now mirrors the real server flow, and the whole suite passes. Codacy follow-ups: - Moved the usings of IllusionTempleContext inside the namespace, as the rest of the code base does, and dropped two that were genuinely unused. The System.Collections.Concurrent one is kept - it is used by the ConcurrentDictionary fields. - Documented the two reflection helpers with SuppressMessage justifications instead of only comments: both are test-only, operate on types from this solution, and are passed hardcoded member names. - Gave the fake IDropGenerator its interface parameter names back and justified them at class level - they are mandated by the interface and deliberately ignored. - Added the missing else branch in AwaitResultAsync. Co-Authored-By: Claude Opus 5 --- .../MiniGames/IllusionTempleContext.cs | 6 ++--- .../IllusionTempleContextTest.cs | 24 ++++++++++++------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/GameLogic/MiniGames/IllusionTempleContext.cs b/src/GameLogic/MiniGames/IllusionTempleContext.cs index ea8e4acc43..5632a54299 100644 --- a/src/GameLogic/MiniGames/IllusionTempleContext.cs +++ b/src/GameLogic/MiniGames/IllusionTempleContext.cs @@ -2,16 +2,14 @@ // Licensed under the MIT License. See LICENSE file in the project root for full license information. // +namespace MUnique.OpenMU.GameLogic.MiniGames; + 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.Inventory; using MUnique.OpenMU.Pathfinding; -using MUnique.OpenMU.Persistence; - -namespace MUnique.OpenMU.GameLogic.MiniGames; /// /// The context of an illusion temple game. diff --git a/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs b/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs index d3f34b66db..86eead4e5f 100644 --- a/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs +++ b/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs @@ -4,6 +4,7 @@ namespace MUnique.OpenMU.Tests; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.DataModel.Configuration.Items; @@ -357,6 +358,7 @@ private static IllusionTempleTeam GetTeamOf(IllusionTempleContext context, Playe /// input reaches this reflection call), used because the field has no public accessor - exposing /// one purely for tests isn't warranted for a single internal implementation detail. /// + [SuppressMessage("Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", Justification = "Test-only code reading a private field of a type from this same solution. The member name is a hardcoded literal - no external input reaches this call - and exposing a public accessor purely for tests isn't warranted for an internal implementation detail.")] private static Player? GetRelicCarrier(IllusionTempleContext context) { var field = typeof(IllusionTempleContext).GetField("_relicCarrier", BindingFlags.NonPublic | BindingFlags.Instance); @@ -385,9 +387,11 @@ private static async ValueTask> EnterPlayersAsync(IllusionTempleCon var result = await context.TryEnterAsync(player).ConfigureAwait(false); Assert.That(result, Is.EqualTo(EnterResult.Success)); - // TryEnterAsync alone doesn't place the player on the event map (that's normally done by - // EnterMiniGameAction.WarpToAsync + the client's map-change acknowledgement) - do the same - // here, so CurrentMap/CurrentMiniGame are set up exactly like a real client join. + // TryEnterAsync alone doesn't place the player on the event map. Mirror what the server + // really does on entry: EnterMiniGameAction warps the player to the event's entrance gate, + // and the client then acknowledges the map change, which is what actually puts him onto the + // mini game's map instance. + await player.WarpToAsync(context.Definition.Entrance!).ConfigureAwait(false); await player.ClientReadyAfterMapChangeAsync().ConfigureAwait(false); players.Add(player); @@ -471,18 +475,19 @@ private static MiniGameDefinition CreateDefinition(IGameContext gameContext, int /// of the reward's drop item group - used to test that a mini game's item reward actually reaches /// the player's inventory, without needing a fully configured drop chance/item pool. /// + [SuppressMessage("Major Code Smell", "S1172:Unused method parameters should be removed", Justification = "The parameters are required by IDropGenerator; this fake deliberately ignores them and always returns the same item.")] private sealed class SingleItemDropGenerator : IDropGenerator { private readonly ItemDefinition _itemDefinition; public SingleItemDropGenerator(ItemDefinition itemDefinition) => this._itemDefinition = itemDefinition; - public ValueTask<(IEnumerable Items, uint? Money)> GenerateItemDropsAsync(MonsterDefinition _1, int _2, Player _3) + public ValueTask<(IEnumerable Items, uint? Money)> GenerateItemDropsAsync(MonsterDefinition monster, int gainedExperience, Player player) => ValueTask.FromResult((Enumerable.Empty(), default(uint?))); - public Item? GenerateItemDrop(DropItemGroup _) => new MUnique.OpenMU.Persistence.BasicModel.Item { Definition = this._itemDefinition }; + public Item? GenerateItemDrop(DropItemGroup group) => new MUnique.OpenMU.Persistence.BasicModel.Item { Definition = this._itemDefinition }; - public (Item? Item, uint? Money, ItemDropEffect DropEffect) GenerateItemDrop(IEnumerable _) + public (Item? Item, uint? Money, ItemDropEffect DropEffect) GenerateItemDrop(IEnumerable groups) => (new MUnique.OpenMU.Persistence.BasicModel.Item { Definition = this._itemDefinition }, null, ItemDropEffect.Undefined); } @@ -559,6 +564,7 @@ private static async ValueTask InvokeProtectedAsync(object target, string method /// is always a hardcoded literal from this test file, never external /// input. /// + [SuppressMessage("Security Hotspot", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", Justification = "Test-only code invoking lifecycle hooks of a type from this same solution, bypassing the real-time countdown that normally drives them (see the class remarks). Every method name passed in is a hardcoded literal from this file - no external input reaches this call.")] private static MethodInfo? FindMethod(Type type, string methodName) { for (var current = type; current is not null; current = current.BaseType) @@ -592,7 +598,9 @@ private static async ValueTask AwaitResultAsync(object? result) { await task.ConfigureAwait(false); } - - // Otherwise the invoked method was synchronous (e.g. "async void") - nothing to await. + else + { + // The invoked method was synchronous (e.g. "async void") - there is nothing to await. + } } } From 24b3b4e8f84e24637740bedfcdf7c4e99ca6e062 Mon Sep 17 00:00:00 2001 From: bulgarashi Date: Sun, 23 Aug 2026 23:18:04 +0200 Subject: [PATCH 09/11] Address the PR review findings Blocking: - Added the missing tag on UpdateVersion.IllusionTempleData (the number collision with AddCastleSiegeData resolved itself when the branch was rebased - it's 104 now). - OnObjectRemovedFromMapAsync no longer warps the leaving player: it runs from inside WarpToAsync, so warping again nested the warps and left the player on two maps with duplicate map-change packets, while also bypassing the per-temple safezone. It's now pure state cleanup; leaving is done by ClaimRewardAsync and the base class's exit handling. - ToIllusionTempleEnterResult actually maps its parameter now, instead of reporting success for every refusal. - Item rewards reached only the first winner, because DoesRewardApply compares parties and a party is disposed once fewer than two members remain. The winner-related predicates moved into overridable IsWinner/IsInWinningParty/IsWinnerOrInWinningParty methods, which the illusion temple answers by team - so every member of the winning team is rewarded. - The 20 second preparation delay is configurable (PreparationDuration). The tests set it to zero, which brings the suite from ~4.8 minutes down to ~5 seconds. Correctness: - Team mates are spread over consecutive tiles again - the previous code incremented a copy of a readonly field, so everyone stacked on one tile. Typo in the comment fixed as well. - Dropped the redundant "player.Party = null", which bypassed KickMySelfAsync and left the player in the old party's member array. - Restraint and Weaken only hit opponents now, and using a special skill requires a running event and a living caster. - The reported experience mirrors what is actually granted, including the per-remaining-second reward type. - Talking to the statue claims the carrier slot atomically, so two players can't both walk away with a relic, and the item is only created after the inventory-space check succeeds (with the orphan deleted if it doesn't). - The relic is taken away when its carrier leaves, so it can't stay in an inventory after the match. - The Ended and WaitingRoom event states are sent now, and the skill-ended view plugin is wired to the magic effect's timeout. - The hardcoded Devias gate is gone; leaving uses the map's configured safezone. Consistency: - The mini game entry refusals are localized PlayerMessage resources instead of hardcoded English strings (including the backtick typo). - Fixed copy-paste documentation in the chat command, the game server state and MiniGameContext.GetSpawnGate. - The chat command reports back when the event isn't configured, which also resolves its CS1998. - Collapsed the identical 383/384 cases in TalkNpcAction and indented the switch properly. - IllusionTempleTeam moved to the MiniGames namespace, matching its folder. - Removed the whitespace noise in MiniGameContext and Player. - GameContextTestHelper takes the maximum level as a parameter instead of changing it globally for every test. - Deduplicated the relic's group/number checks behind IsRelicDefinition. - The update plugin removes the obsolete 658-668 statue spawns, so existing databases don't keep the placeholders next to the new ones. - FinishesWhenTooFewPlayersRemainAsync asserts that the match actually ends, not just the precondition. Co-Authored-By: Claude Opus 5 --- .../MiniGames/IllusionTempleContext.cs | 270 +++++++++++++----- src/GameLogic/MiniGames/IllusionTempleTeam.cs | 2 +- src/GameLogic/MiniGames/MiniGameContext.cs | 54 +++- .../MiniGames/EnterMiniGameAction.cs | 22 +- src/GameLogic/PlayerActions/TalkNpcAction.cs | 23 +- ...artIllusionTempleEventChatCommandPlugIn.cs | 11 +- .../IllusionTempleGameServerState.cs | 2 +- .../Properties/PlayerMessage.Designer.cs | 81 ++++++ src/GameLogic/Properties/PlayerMessage.resx | 27 ++ .../RemoteView/MiniGames/Extensions.cs | 2 +- .../Updates/IllusionTempleDataUpdatePlugIn.cs | 31 ++ .../GameContextTestHelper.cs | 5 +- .../IllusionTempleContextTest.cs | 16 +- 13 files changed, 419 insertions(+), 127 deletions(-) diff --git a/src/GameLogic/MiniGames/IllusionTempleContext.cs b/src/GameLogic/MiniGames/IllusionTempleContext.cs index 5632a54299..f42972cf8f 100644 --- a/src/GameLogic/MiniGames/IllusionTempleContext.cs +++ b/src/GameLogic/MiniGames/IllusionTempleContext.cs @@ -6,8 +6,10 @@ namespace MUnique.OpenMU.GameLogic.MiniGames; using System.Collections.Concurrent; using System.Threading; +using MUnique.OpenMU.DataModel.Configuration.Items; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames; using MUnique.OpenMU.GameLogic.Views.Inventory; using MUnique.OpenMU.Pathfinding; @@ -53,10 +55,14 @@ public sealed class IllusionTempleContext : MiniGameContext private const short StatueNPC = 380; /// - /// How long the players see the "Preparation" state (still behind the arena barriers) before the - /// battle actually starts. + /// The item group of the sacred relic ("Cursed Castle Water"). /// - private static readonly TimeSpan PreparationDuration = TimeSpan.FromSeconds(20); + private const byte RelicItemGroup = 14; + + /// + /// The item number of the sacred relic ("Cursed Castle Water"). + /// + private const short RelicItemNumber = 64; /// /// How long it takes after a scored point for the next stone statue to spawn - matches the @@ -217,6 +223,13 @@ public IllusionTempleContext(MiniGameMapKey key, MiniGameDefinition definition, /// public IllusionTempleScore Score { get; } = new(); + /// + /// Gets or sets how long the players see the "Preparation" state (still behind the arena barriers) + /// before the battle actually starts. Configurable so that tests, which drive the game start + /// directly, don't have to wait out the real countdown. + /// + public TimeSpan PreparationDuration { get; set; } = TimeSpan.FromSeconds(20); + /// protected override TimeSpan RemainingTime => this._remainingTime; @@ -230,6 +243,24 @@ public IllusionTempleContext(MiniGameMapKey key, MiniGameDefinition definition, ? this._teams.FirstOrDefault(entry => entry.Value == leadingTeam).Key : null; + /// + /// + /// This event decides its winners by team, not by party: every member of the leading team won. + /// The temporary parties created at game start can't be used for that, because a party is disposed + /// as soon as fewer than two of its members remain, which would silently drop the rewards of + /// everyone but the first winner. + /// + protected override bool IsWinner(Player player) + => this.Score.LeadingTeam is { } leadingTeam + && this._teams.TryGetValue(player, out var team) + && team == leadingTeam; + + /// + /// + /// The winning team takes the role of the winning party here - see . + /// + protected override bool IsInWinningParty(Player player) => this.IsWinner(player); + /// /// /// Two teams fighting each other need at least 2 players - configurable per temple via @@ -265,27 +296,46 @@ public IllusionTempleContext(MiniGameMapKey key, MiniGameDefinition definition, }; } + /// + /// + /// Tells the entering player's client that he's waiting for the match to start, so it can show the + /// event's waiting state. This is only sent to him, not to the other participants. + /// + public override async ValueTask TryEnterAsync(Player player) + { + var result = await base.TryEnterAsync(player).ConfigureAwait(false); + if (result == EnterResult.Success) + { + await player.InvokeViewPlugInAsync( + p => p.ChangeEventStateAsync((byte)this.Definition.GameLevel, IllusionTempleEventStatus.WaitingRoom)).ConfigureAwait(false); + } + + return result; + } + /// /// Handles a player talking to the stone statue (NPC 380) which holds the sacred relic. /// /// The player who talked to the statue. public async ValueTask TalkToNpcStoneStatueAsync(Player player) { - if (this._relicCarrier is not null) + // Claim the carrier slot before the first await - otherwise two players talking to the same + // statue at the same time could both pass the check and walk away with a relic each. + if (Interlocked.CompareExchange(ref this._relicCarrier, player, null) is not null) { // Somebody already carries the relic - the statue that granted it must already be gone. return; } - var cursedCastleWater = player.GameContext.Configuration.Items.First(item => item.Group == 14 && item.Number == 64); - var item = player.PersistenceContext.CreateNew(); - item.Definition = cursedCastleWater; + item.Definition = player.GameContext.Configuration.Items.First(IsRelicDefinition); - var invIndex = player.Inventory?.CheckInvSpace(item); - if (invIndex is null) + if (player.Inventory?.CheckInvSpace(item) is null) { - await player.ShowBlueMessageAsync("Your Inventory is full!").ConfigureAwait(false); + // Hand the claim back, and don't leave the unused item behind as an orphan entity. + this._relicCarrier = null; + await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false); + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.InventoryFull)).ConfigureAwait(false); return; } @@ -298,7 +348,6 @@ public async ValueTask TalkToNpcStoneStatueAsync(Player player) await statue.DisposeAsync().ConfigureAwait(false); } - this._relicCarrier = player; await this.ForEachPlayerAsync(p => p.InvokeViewPlugInAsync( vp => vp.ShowHolyItemRelicsAsync(player.Id, player.Name)).AsTask()).ConfigureAwait(false); } @@ -316,7 +365,7 @@ public async ValueTask TalkToNpcTeamStorageAsync(int npcNumber, Player player) } var relicItem = player.Inventory?.Items - .FirstOrDefault(i => i.Definition?.Group == 14 && i.Definition?.Number == 64); + .FirstOrDefault(i => IsRelicDefinition(i.Definition)); if (relicItem is null) { @@ -406,7 +455,7 @@ private async ValueTask SpawnRandomStatueAsync() /// The map object index of the skill's target, if any. public async ValueTask UseSkillAsync(Player player, ushort skillNumber, ushort targetObjectIndex) { - if (!this._teams.ContainsKey(player)) + if (!this.IsEventRunning || !player.IsAlive || !this._teams.ContainsKey(player)) { return; } @@ -453,6 +502,7 @@ private async ValueTask UseOrderOfProtectionAsync(Player player) .ToArray(); var duration = effectDefinition.Duration?.ConstantValue.Value ?? 15f; var magicEffect = new MagicEffect(TimeSpan.FromSeconds(duration), effectDefinition, elements); + this.NotifyWhenSkillEffectEnds(magicEffect, OrderOfProtectionSkillNumber, player); await player.MagicEffectList.AddEffectAsync(magicEffect).ConfigureAwait(false); return true; } @@ -462,7 +512,9 @@ private async ValueTask UseOrderOfProtectionAsync(Player player) /// private async ValueTask UseRestraintAsync(Player player, IAttackable? target) { - if (target is null || target == player || player.GetDistanceTo(target) > SpecialSkillMaximumDistance) + if (target is null + || !this.IsHostileTarget(player, target) + || player.GetDistanceTo(target) > SpecialSkillMaximumDistance) { return false; } @@ -478,6 +530,7 @@ private async ValueTask UseRestraintAsync(Player player, IAttackable? targ .ToArray(); var duration = effectDefinition.Duration?.ConstantValue.Value ?? 15f; var magicEffect = new MagicEffect(TimeSpan.FromSeconds(duration), effectDefinition, elements); + this.NotifyWhenSkillEffectEnds(magicEffect, RestraintSkillNumber, target); await target.MagicEffectList.AddEffectAsync(magicEffect).ConfigureAwait(false); return true; } @@ -505,7 +558,9 @@ private async ValueTask UseTrackingAsync(Player player) /// private ValueTask UseWeakenAsync(Player player, IAttackable? target) { - if (target is null || target == player || player.GetDistanceTo(target) > SpecialSkillMaximumDistance) + if (target is null + || !this.IsHostileTarget(player, target) + || player.GetDistanceTo(target) > SpecialSkillMaximumDistance) { return ValueTask.FromResult(false); } @@ -545,7 +600,6 @@ protected override async ValueTask OnGameStartAsync(ICollection players) { // Adding player to team AlliedForces or IllusionForces var player = playersArray[i]; - player.Party = null; var team = i % 2 == 0 ? IllusionTempleTeam.AlliedForces : IllusionTempleTeam.IllusionForces; if (!this._teams.TryAdd(player, team)) { @@ -567,7 +621,8 @@ protected override async ValueTask OnGameStartAsync(ICollection players) this.Definition.MaximumPlayerCount); } - await this.TeleportToStartCoordinatesAsync(team, player).ConfigureAwait(false); + // i / 2 is the player's index within his own team, since the teams alternate. + await this.TeleportToStartCoordinatesAsync(team, player, i / 2).ConfigureAwait(false); } await base.OnGameStartAsync(players).ConfigureAwait(false); @@ -583,7 +638,7 @@ await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync /// /// When the player who left carried the relic (character switch, disconnect or leaving the event - /// on purpose), drop it so that it can be picked up again by the remaining participants. The player - /// himself is sent back to Devias and removed from his temporary event party, just like a player who - /// finishes a match normally in . If fewer than - /// players remain afterwards, the match can't continue and is ended right away. + /// on purpose), drop it so that it can be picked up again by the remaining participants, and remove + /// him from the event's own bookkeeping. This handler deliberately does not warp the player + /// anywhere: it runs from within (which removes the player from his + /// current map before placing him at the target gate), so warping again from here would nest the + /// warps and leave the player on two maps with duplicate map-change packets. Leaving the event is + /// handled by and by the base class's exit handling, both of which + /// respect the map's configured safezone. If fewer than players + /// remain afterwards, the match can't continue and is ended right away. /// protected override async ValueTask OnObjectRemovedFromMapAsync((GameMap Map, ILocateable Object) args) { @@ -708,16 +767,6 @@ protected override async ValueTask OnObjectRemovedFromMapAsync((GameMap Map, ILo await party.KickMySelfAsync(player).ConfigureAwait(false); } - var devias = player.GameContext.Configuration.Maps.First(map => map.Number == 2); - await player.WarpToAsync(new ExitGate - { - Map = devias, - X1 = 197, - Y1 = 35, - X2 = 218, - Y2 = 50, - }).ConfigureAwait(false); - // Otherwise he'd keep showing up as an alive team mate on the mini map of his former team. this._teams.TryRemove(player, out _); this._skillPoints.TryRemove(player, out _); @@ -748,7 +797,7 @@ private async ValueTask DropRelicIfCarriedByAsync(Player player) } var relicItem = player.Inventory?.Items - .FirstOrDefault(i => i.Definition?.Group == 14 && i.Definition?.Number == 64); + .FirstOrDefault(i => IsRelicDefinition(i.Definition)); if (relicItem is null || player.CurrentMap is not { } map) { @@ -777,8 +826,7 @@ protected override async void OnItemDroppedOnMap(DroppedItem item) try { if (this._relicCarrier is not { } carrier - || item.Item.Definition?.Group != 14 - || item.Item.Definition?.Number != 64) + || !IsRelicDefinition(item.Item.Definition)) { return; } @@ -800,8 +848,7 @@ protected async override ValueTask OnPlayerPickedUpItemAsync((Player Picker, ILo { if (this._relicCarrier is null && args.DroppedItem is DroppedItem droppedItem - && droppedItem.Item.Definition?.Group == 14 - && droppedItem.Item.Definition?.Number == 64) + && IsRelicDefinition(droppedItem.Item.Definition)) { this._relicCarrier = args.Picker; await this.ShowGoldenMessageAsync(nameof(PlayerMessage.IllusionTempleRelicPickedUpFormat), args.Picker.Name).ConfigureAwait(false); @@ -837,15 +884,13 @@ protected async override ValueTask ShowScoreAsync(Player player) /// IllusionTempleRewardRequest (0xBF05) packet - the client sends it when the player clicks the /// "Close" button on the result dialog. Experience has already been granted automatically in /// , so this only grants the remaining reward types (e.g. an item drop) - /// to winners, and finally warps the requesting player to Devias - regardless of whether he won, - /// lost, or already claimed his reward before. + /// to winners, and finally sends the requesting player to the map's configured safezone - + /// regardless of whether he won, lost, or already claimed his reward before. /// /// The player who claims his reward. public async ValueTask ClaimRewardAsync(Player player) { - if (this._claimedRewards.TryAdd(player, true) - && this._teams.TryGetValue(player, out var team) - && this.Score.LeadingTeam == team) + if (this._claimedRewards.TryAdd(player, true) && this.IsWinner(player)) { var rank = this._winnerRanks.GetValueOrDefault(player, 1); var remainingRewards = this.Definition.Rewards.Where(r => @@ -857,15 +902,8 @@ r.RewardType is not (MiniGameRewardType.Experience or MiniGameRewardType.Experie } } - var devias = player.GameContext.Configuration.Maps.First(map => map.Number == 2); - await player.WarpToAsync(new ExitGate - { - Map = devias, - X1 = 197, - Y1 = 35, - X2 = 218, - Y2 = 50, - }).ConfigureAwait(false); + await this.RemoveRelicFromInventoryAsync(player).ConfigureAwait(false); + await player.WarpToSafezoneAsync().ConfigureAwait(false); } /// @@ -880,31 +918,34 @@ await player.WarpToAsync(new ExitGate /// protected override async ValueTask GameEndedAsync(ICollection finishers) { - if (this.Score.LeadingTeam is { } winningTeam) + var remainingSeconds = (int)this.RemainingTime.TotalSeconds; + foreach (var winner in finishers.Where(this.IsWinner)) { - var winners = finishers - .Where(player => this._teams.TryGetValue(player, out var team) && team == winningTeam) - .ToList(); + var rank = this._winnerRanks.Count + 1; + this._winnerRanks[winner] = rank; - var rank = 0; - foreach (var winner in winners) - { - rank++; - this._winnerRanks[winner] = rank; + var experienceRewards = this.Definition.Rewards + .Where(r => r.RewardType is MiniGameRewardType.Experience or MiniGameRewardType.ExperiencePerRemainingSeconds + && this.DoesRewardApply(winner, rank, r)) + .ToList(); - var experienceRewards = this.Definition.Rewards - .Where(r => r.RewardType is MiniGameRewardType.Experience or MiniGameRewardType.ExperiencePerRemainingSeconds - && this.DoesRewardApply(winner, rank, r)) - .ToList(); - this._grantedExperience[winner] = experienceRewards.Sum(r => r.RewardAmount); + // Mirror what GiveRewardAsync actually grants, so the score board doesn't lie: the + // per-second reward type multiplies its amount by the remaining seconds. + this._grantedExperience[winner] = experienceRewards.Sum(r => r.RewardType == MiniGameRewardType.ExperiencePerRemainingSeconds + ? r.RewardAmount * Math.Max(0, remainingSeconds) + : r.RewardAmount); - foreach (var reward in experienceRewards) - { - await this.GiveRewardAsync(winner, reward).ConfigureAwait(false); - } + foreach (var reward in experienceRewards) + { + await this.GiveRewardAsync(winner, reward).ConfigureAwait(false); } } + // Tell the clients that the match is over, so they close the event interface. + var templeNumber = (byte)this.Definition.GameLevel; + await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync( + p => p.ChangeEventStateAsync(templeNumber, IllusionTempleEventStatus.Ended)).AsTask()).ConfigureAwait(false); + // base.GameEndedAsync() shows the score table to every finisher (via ShowScoreAsync), which // reads this._teams - so it has to run before anyone leaves the map. Warping a player off this // map fires OnObjectRemovedFromMapAsync, which removes him from _teams; doing that first would @@ -916,20 +957,93 @@ protected override async ValueTask GameEndedAsync(ICollection finishers) await base.GameEndedAsync(finishers).ConfigureAwait(false); } - private async ValueTask TeleportToStartCoordinatesAsync(IllusionTempleTeam team, Player player) + /// + /// Determines whether the given target may be hit by one of the caster's offensive special skills: + /// it must not be the caster himself, and - if it's another participant - it has to belong to the + /// opposing team, so that team mates can't be frozen or drained. + /// + /// The caster. + /// The target of the skill. + /// true, if the target may be hit; otherwise, false. + private bool IsHostileTarget(Player player, IAttackable? target) { - var cordinatesAlliedForces = this.alliedForcesCoordinates; - var cordinatesIllusionForces = this.illusionForcesCoordinates; - if (team == IllusionTempleTeam.AlliedForces) + if (target is null || ReferenceEquals(target, player)) { - cordinatesAlliedForces += new Point(1, 0); // every player on differend point (x,y) - await player.MoveAsync(cordinatesAlliedForces).ConfigureAwait(false); + return false; } - else + + if (target is not Player targetPlayer) { - cordinatesIllusionForces += new Point(1, 0); // every player on differend point (x,y) - await player.MoveAsync(cordinatesIllusionForces).ConfigureAwait(false); + // Not a participant (e.g. an arena monster) - always a valid target. + return true; } + + return this._teams.TryGetValue(player, out var ownTeam) + && this._teams.TryGetValue(targetPlayer, out var targetTeam) + && ownTeam != targetTeam; + } + + /// + /// Announces to all participants that a timed special skill wore off on the given object, so their + /// clients can drop the corresponding effect indicator. + /// + /// The magic effect which carries the skill. + /// The number of the special skill. + /// The object the effect was applied to. + private void NotifyWhenSkillEffectEnds(MagicEffect magicEffect, ushort skillNumber, IIdentifiable affectedObject) + { + magicEffect.EffectTimeOut += _ => this.ForEachPlayerAsync( + p => p.InvokeViewPlugInAsync( + view => view.ShowSkillEndedAsync(skillNumber, affectedObject.Id)).AsTask()); + } + + /// + /// Takes the sacred relic away from a player who still has it when he leaves the event, so it + /// doesn't stay in his inventory forever - it only exists for the duration of a match. + /// + /// The player whose inventory should be cleaned up. + private async ValueTask RemoveRelicFromInventoryAsync(Player player) + { + if (player.Inventory?.Items.FirstOrDefault(i => IsRelicDefinition(i.Definition)) is not { } relicItem) + { + return; + } + + if (ReferenceEquals(this._relicCarrier, player)) + { + this._relicCarrier = null; + } + + await player.Inventory.RemoveItemAsync(relicItem).ConfigureAwait(false); + await player.PersistenceContext.DeleteAsync(relicItem).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.ItemDropResultAsync(relicItem.ItemSlot, true)).ConfigureAwait(false); + } + + /// + /// Determines whether the given item definition is the event's sacred relic. + /// + /// The item definition to check. + /// true, if it's the sacred relic; otherwise, false. + private static bool IsRelicDefinition(ItemDefinition? itemDefinition) + => itemDefinition is { Group: RelicItemGroup, Number: RelicItemNumber }; + + /// + /// Moves a player to his team's starting chamber. + /// + /// The team of the player. + /// The player to move. + /// + /// The index of the player within his team, used to spread the members over consecutive tiles + /// instead of stacking them all on the same spot. + /// + private async ValueTask TeleportToStartCoordinatesAsync(IllusionTempleTeam team, Player player, int indexInTeam) + { + var teamStart = team == IllusionTempleTeam.AlliedForces + ? this.alliedForcesCoordinates + : this.illusionForcesCoordinates; + + var target = new Point((byte)(teamStart.X + indexInTeam), teamStart.Y); + await player.MoveAsync(target).ConfigureAwait(false); } private async ValueTask ShowRemainingTimeLoopAsync(CancellationToken cancellationToken) diff --git a/src/GameLogic/MiniGames/IllusionTempleTeam.cs b/src/GameLogic/MiniGames/IllusionTempleTeam.cs index 1f3fa585bd..403597a4c8 100644 --- a/src/GameLogic/MiniGames/IllusionTempleTeam.cs +++ b/src/GameLogic/MiniGames/IllusionTempleTeam.cs @@ -2,7 +2,7 @@ // Licensed under the MIT License. See LICENSE file in the project root for full license information. // -namespace MUnique.OpenMU.GameLogic; +namespace MUnique.OpenMU.GameLogic.MiniGames; /// /// Defines the team of a illusion temple. diff --git a/src/GameLogic/MiniGames/MiniGameContext.cs b/src/GameLogic/MiniGames/MiniGameContext.cs index 769444bf69..266f39c522 100644 --- a/src/GameLogic/MiniGames/MiniGameContext.cs +++ b/src/GameLogic/MiniGames/MiniGameContext.cs @@ -34,7 +34,7 @@ public class MiniGameContext : AsyncDisposable, IEventStateProvider private readonly ConcurrentDictionary _currentSpawnWaves = new(); private readonly List _remainingEvents = new(); - + private Stopwatch? _elapsedTimeSinceStart; /// @@ -88,7 +88,6 @@ public MiniGameContext(MiniGameMapKey key, MiniGameDefinition definition, IGameC /// public bool IsEventRunning => this.State == MiniGameState.Playing; - /// /// Gets the player count. @@ -148,7 +147,7 @@ public int PlayerCount /// /// The player which tries to enter. /// A value indicating whether entering had success. - public async ValueTask TryEnterAsync(Player player) + public virtual async ValueTask TryEnterAsync(Player player) { using (await this._enterLock.WriterLockAsync().ConfigureAwait(false)) { @@ -205,10 +204,10 @@ public virtual bool IsSkillAllowed(Skill skill, Player attacker, IAttackable tar } /// - /// Gets spown gate + /// Gets the spawn gate at which the specified player should be placed on this game's map. /// - /// - /// + /// The player for which the spawn gate is requested. + /// The spawn gate, or null if this game doesn't define one for the player. public virtual ExitGate? GetSpawnGate(Player player) => null; /// @@ -891,6 +890,37 @@ private async ValueTask MovePlayersToSafezoneAsync() } } + /// + /// Determines whether the specified player is a winner of this game. + /// + /// The player. + /// true, if the player is a winner; otherwise, false. + protected virtual bool IsWinner(Player player) => this.Winner == player; + + /// + /// Determines whether the specified player belongs to the winning party. Games which don't decide + /// their winners by party (e.g. the team based illusion temple) override this accordingly. + /// + /// The player. + /// true, if the player belongs to the winning party; otherwise, false. + protected virtual bool IsInWinningParty(Player player) + => this.Winner?.Party is { } winningParty && winningParty == player.Party; + + /// + /// Determines whether the specified player is a winner himself, or belongs to the winning party. + /// + /// The player. + /// true, if the player won or belongs to the winning party; otherwise, false. + protected virtual bool IsWinnerOrInWinningParty(Player player) + => this.IsWinner(player) || this.IsInWinningParty(player); + + /// + /// Determines whether the specified reward applies to the specified player. + /// + /// The player. + /// The rank of the player in this game. + /// The reward. + /// true, if the reward applies; otherwise, false. protected bool DoesRewardApply(Player player, int playerRank, MiniGameReward reward) { if (reward.Rank is not null && reward.Rank != playerRank) @@ -918,26 +948,22 @@ protected bool DoesRewardApply(Player player, int playerRank, MiniGameReward rew return false; } - if (reward.RequiredSuccess.HasFlag(MiniGameSuccessFlags.Winner) && this.Winner != player) + if (reward.RequiredSuccess.HasFlag(MiniGameSuccessFlags.Winner) && !this.IsWinner(player)) { return false; } - if (reward.RequiredSuccess.HasFlag(MiniGameSuccessFlags.Loser) - && (this.Winner == player || (player.Party == this.Winner?.Party && player.Party is not null))) + if (reward.RequiredSuccess.HasFlag(MiniGameSuccessFlags.Loser) && this.IsWinnerOrInWinningParty(player)) { return false; } - if (reward.RequiredSuccess.HasFlag(MiniGameSuccessFlags.WinningParty) - && (this.Winner?.Party is null || this.Winner.Party != player.Party)) + if (reward.RequiredSuccess.HasFlag(MiniGameSuccessFlags.WinningParty) && !this.IsInWinningParty(player)) { return false; } - if (reward.RequiredSuccess.HasFlag(MiniGameSuccessFlags.WinnerOrInWinningParty) - && (this.Winner?.Party is null || this.Winner.Party != player.Party) - && this.Winner != player) + if (reward.RequiredSuccess.HasFlag(MiniGameSuccessFlags.WinnerOrInWinningParty) && !this.IsWinnerOrInWinningParty(player)) { return false; } diff --git a/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs b/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs index 5f2abc2388..afb2f25277 100644 --- a/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs +++ b/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs @@ -40,7 +40,7 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam || (miniGameDefinition.RequiresMasterClass && !player.SelectedCharacter.CharacterClass.IsMasterClass) || player.CurrentMiniGame is not null) { - await ShowRefusalAsync(player, $"You can't enter this event.").ConfigureAwait(false); + await ShowRefusalAsync(player, nameof(PlayerMessage.MiniGameEnterFailed)).ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.Failed)).ConfigureAwait(false); return; } @@ -52,42 +52,42 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam var requiresMasterLevel = miniGameDefinition.RequiresMasterClass; if (characterLevel < minLevel || (requiresMasterLevel && player.SelectedCharacter?.CharacterClass?.IsMasterClass is not true)) { - await ShowRefusalAsync(player, $"Your level is too low. You need to be at least level {minLevel} to enter this event.").ConfigureAwait(false); + await ShowRefusalAsync(player, nameof(PlayerMessage.MiniGameCharacterLevelTooLowFormat), minLevel).ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.CharacterLevelTooLow)).ConfigureAwait(false); return; } if (characterLevel > maxLevel) { - await ShowRefusalAsync(player, $"Your level is too high. You need to be at most level {maxLevel} to enter this event.").ConfigureAwait(false); + await ShowRefusalAsync(player, nameof(PlayerMessage.MiniGameCharacterLevelTooHighFormat), maxLevel).ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.CharacterLevelTooHigh)).ConfigureAwait(false); return; } if (!this.CheckTicketItem(miniGameDefinition, player, gameTicketInventoryIndex, out var ticketItem)) { - await ShowRefusalAsync(player, $"You need a ticket to enter this event.").ConfigureAwait(false); + await ShowRefusalAsync(player, nameof(PlayerMessage.MiniGameTicketRequired)).ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.Failed)).ConfigureAwait(false); return; } if (!this.CheckEntranceFee(miniGameDefinition, player, out var entranceFee)) { - await ShowRefusalAsync(player, $"You need {miniGameDefinition.EntranceFee} zen to enter this event.").ConfigureAwait(false); + await ShowRefusalAsync(player, nameof(PlayerMessage.MiniGameEntranceFeeRequiredFormat), miniGameDefinition.EntranceFee).ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.NotEnoughMoney)).ConfigureAwait(false); return; } if (!this.CheckPlayerKillState(miniGameDefinition, player)) { - await ShowRefusalAsync(player, $"Killers can`t enter!").ConfigureAwait(false); + await ShowRefusalAsync(player, nameof(PlayerMessage.MiniGamePlayerKillersCantEnter)).ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.PlayerKillerCantEnter)).ConfigureAwait(false); return; } if (player.GuildWarContext is { State: GuildWarState.Started or GuildWarState.Requested }) { - await ShowRefusalAsync(player, "You can't enter this event during a guild war.").ConfigureAwait(false); + await ShowRefusalAsync(player, nameof(PlayerMessage.MiniGameNotDuringGuildWar)).ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.Failed)).ConfigureAwait(false); return; } @@ -99,7 +99,7 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam if (miniGameStrategy is not null && await miniGameStrategy.GetDurationUntilNextStartAsync(player.GameContext, miniGameDefinition).ConfigureAwait(false) != TimeSpan.Zero) { - await ShowRefusalAsync(player, $"{miniGameDefinition.Name} is not open right now.").ConfigureAwait(false); + await ShowRefusalAsync(player, nameof(PlayerMessage.MiniGameNotOpenFormat), miniGameDefinition.Name).ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.NotOpen)).ConfigureAwait(false); return; } @@ -156,9 +156,11 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam /// was requested without an open npc dialog. /// /// The player which tried to enter. - /// The reason, in plain words. - private static async ValueTask ShowRefusalAsync(Player player, string reason) + /// The resource key of the reason, see . + /// The format arguments of the message. + private static async ValueTask ShowRefusalAsync(Player player, string messageKey, params object?[] arguments) { + var reason = player.GetLocalizedMessage(messageKey, arguments); if (player.OpenedNpc is { } npc) { await player.InvokeViewPlugInAsync(p => p.ShowMessageOfObjectAsync(reason, npc)).ConfigureAwait(false); diff --git a/src/GameLogic/PlayerActions/TalkNpcAction.cs b/src/GameLogic/PlayerActions/TalkNpcAction.cs index 1c2d7dc9e7..8e3d40dc2d 100644 --- a/src/GameLogic/PlayerActions/TalkNpcAction.cs +++ b/src/GameLogic/PlayerActions/TalkNpcAction.cs @@ -83,21 +83,16 @@ private async ValueTask ShowDialogOfOpenedNpcAsync(Player player) { switch (player.OpenedNpc.Definition.Number) { - case 380: - // Stone Statue - await illusionTemple.TalkToNpcStoneStatueAsync(player).ConfigureAwait(false); - break; - case 383: - // Alliance Item Storage, or Illusion Item Storage on a client where 384 works. - await illusionTemple.TalkToNpcTeamStorageAsync(player.OpenedNpc.Definition.Number, player).ConfigureAwait(false); - break; - case 384: - // Alliance Item Storage, or Illusion Item Storage on a client where 384 works. - await illusionTemple.TalkToNpcTeamStorageAsync(player.OpenedNpc.Definition.Number, player).ConfigureAwait(false); - break; + case 380: // Stone Statue + await illusionTemple.TalkToNpcStoneStatueAsync(player).ConfigureAwait(false); + break; + case 383: // Alliance Item Storage + case 384: // Illusion Item Storage + await illusionTemple.TalkToNpcTeamStorageAsync(player.OpenedNpc.Definition.Number, player).ConfigureAwait(false); + break; default: - await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.TalkingNotImplementedFormat), npcStats.Number, npcStats.Designation).ConfigureAwait(false); - break; + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.TalkingNotImplementedFormat), npcStats.Number, npcStats.Designation).ConfigureAwait(false); + break; } } else diff --git a/src/GameLogic/PlugIns/ChatCommands/StartIllusionTempleEventChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/StartIllusionTempleEventChatCommandPlugIn.cs index 51d7490bd9..39a965d5b6 100644 --- a/src/GameLogic/PlugIns/ChatCommands/StartIllusionTempleEventChatCommandPlugIn.cs +++ b/src/GameLogic/PlugIns/ChatCommands/StartIllusionTempleEventChatCommandPlugIn.cs @@ -9,7 +9,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; using MUnique.OpenMU.PlugIns; /// -/// A chat command plugin which handles the startcc command. +/// A chat command plugin which handles the startit command. /// [Guid("A990270E-B9C6-4445-BBA9-56367A90D42D")] [PlugIn] @@ -28,7 +28,12 @@ public class StartIllusionTempleEventChatCommandPlugIn : IChatCommandPlugIn /// public async ValueTask HandleCommandAsync(Player player, string command) { - var illusionTemple = player.GameContext.PlugInManager.GetStrategy(MiniGameType.IllusionTemple); - illusionTemple?.ForceStart(); + if (player.GameContext.PlugInManager.GetStrategy(MiniGameType.IllusionTemple) is not { } illusionTemple) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.MiniGameNotConfigured)).ConfigureAwait(false); + return; + } + + illusionTemple.ForceStart(); } } \ No newline at end of file diff --git a/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleGameServerState.cs b/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleGameServerState.cs index 7f05f30774..54daa47ae2 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleGameServerState.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleGameServerState.cs @@ -5,7 +5,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; /// -/// The state of a game server state for a chaos castle event. +/// The state of a game server for an illusion temple event. /// public class IllusionTempleGameServerState : PeriodicTaskGameServerState { diff --git a/src/GameLogic/Properties/PlayerMessage.Designer.cs b/src/GameLogic/Properties/PlayerMessage.Designer.cs index a243b69c13..9a21c03d7a 100644 --- a/src/GameLogic/Properties/PlayerMessage.Designer.cs +++ b/src/GameLogic/Properties/PlayerMessage.Designer.cs @@ -1545,6 +1545,87 @@ public static string IllusionTempleStatueSpawnedMessage { } } + /// + /// Looks up a localized string similar to You can't enter this event.. + /// + public static string MiniGameEnterFailed { + get { + return ResourceManager.GetString("MiniGameEnterFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This event isn't configured on this server and can't be started.. + /// + public static string MiniGameNotConfigured { + get { + return ResourceManager.GetString("MiniGameNotConfigured", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your level is too low. You need to be at least level {0} to enter this event.. + /// + public static string MiniGameCharacterLevelTooLowFormat { + get { + return ResourceManager.GetString("MiniGameCharacterLevelTooLowFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your level is too high. You need to be at most level {0} to enter this event.. + /// + public static string MiniGameCharacterLevelTooHighFormat { + get { + return ResourceManager.GetString("MiniGameCharacterLevelTooHighFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need a ticket to enter this event.. + /// + public static string MiniGameTicketRequired { + get { + return ResourceManager.GetString("MiniGameTicketRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need {0} zen to enter this event.. + /// + public static string MiniGameEntranceFeeRequiredFormat { + get { + return ResourceManager.GetString("MiniGameEntranceFeeRequiredFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Killers can't enter!. + /// + public static string MiniGamePlayerKillersCantEnter { + get { + return ResourceManager.GetString("MiniGamePlayerKillersCantEnter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You can't enter this event during a guild war.. + /// + public static string MiniGameNotDuringGuildWar { + get { + return ResourceManager.GetString("MiniGameNotDuringGuildWar", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} is not open right now.. + /// + public static string MiniGameNotOpenFormat { + get { + return ResourceManager.GetString("MiniGameNotOpenFormat", resourceCulture); + } + } + /// /// Looks up a localized string similar to The battle has begun!. /// diff --git a/src/GameLogic/Properties/PlayerMessage.resx b/src/GameLogic/Properties/PlayerMessage.resx index 3e32bc51e6..cd3eb45ebb 100644 --- a/src/GameLogic/Properties/PlayerMessage.resx +++ b/src/GameLogic/Properties/PlayerMessage.resx @@ -435,6 +435,33 @@ A new sacred relic has appeared! + + You can't enter this event. + + + This event isn't configured on this server and can't be started. + + + Your level is too low. You need to be at least level {0} to enter this event. + + + Your level is too high. You need to be at most level {0} to enter this event. + + + You need a ticket to enter this event. + + + You need {0} zen to enter this event. + + + Killers can't enter! + + + You can't enter this event during a guild war. + + + {0} is not open right now. + The battle has begun! diff --git a/src/GameServer/RemoteView/MiniGames/Extensions.cs b/src/GameServer/RemoteView/MiniGames/Extensions.cs index 1140e600db..02cce6a979 100644 --- a/src/GameServer/RemoteView/MiniGames/Extensions.cs +++ b/src/GameServer/RemoteView/MiniGames/Extensions.cs @@ -83,6 +83,6 @@ public static ChaosCastleEnterResult.EnterResult ToChaosCastleEnterResult(this E /// public static byte ToIllusionTempleEnterResult(this EnterResult enterResult) { - return 0; + return enterResult == EnterResult.Success ? (byte)0 : (byte)1; } } \ No newline at end of file diff --git a/src/Persistence/Initialization/Updates/IllusionTempleDataUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/IllusionTempleDataUpdatePlugIn.cs index 979272df31..a8bfcb7cff 100644 --- a/src/Persistence/Initialization/Updates/IllusionTempleDataUpdatePlugIn.cs +++ b/src/Persistence/Initialization/Updates/IllusionTempleDataUpdatePlugIn.cs @@ -55,6 +55,17 @@ public class IllusionTempleDataUpdatePlugIn : UpdatePlugInBase /// private const short IllusionItemStorageNumber = 384; + /// + /// The lowest NPC number of the obsolete "Cursed Statue" / "Captured Stone Statue" spawns, which + /// this update removes from the temple maps. + /// + private const short ObsoleteStatueRangeStart = 658; + + /// + /// The highest NPC number of the obsolete "Cursed Statue" / "Captured Stone Statue" spawns. + /// + private const short ObsoleteStatueRangeEnd = 668; + /// /// The lowest NPC number of the roaming "Illusion Sorc. Spirit" arena monsters, across all temples. /// @@ -324,6 +335,8 @@ private void AddMapSpawns(IContext context, GameConfiguration gameConfiguration) continue; } + this.RemoveObsoleteStatueSpawns(map); + if (map.MonsterSpawns.Any(spawn => spawn.MonsterDefinition == stoneStatue)) { continue; @@ -361,6 +374,24 @@ private void AddMapSpawns(IContext context, GameConfiguration gameConfiguration) } } + /// + /// Removes the previous "Cursed Statue" / "Captured Stone Statue" spawns (NPC 658 to 668) from a + /// temple map. They were placeholders based on the wrong NPC numbers - the event actually uses a + /// single "Stone Statue" (380), which is picked at random from a small pool of positions. Leaving + /// the old ones in place would litter the arena with statues that have no function. + /// + /// The temple map to clean up. + private void RemoveObsoleteStatueSpawns(GameMapDefinition map) + { + var obsoleteSpawns = map.MonsterSpawns + .Where(spawn => spawn.MonsterDefinition?.Number is >= ObsoleteStatueRangeStart and <= ObsoleteStatueRangeEnd) + .ToList(); + foreach (var spawn in obsoleteSpawns) + { + map.MonsterSpawns.Remove(spawn); + } + } + private void AddSpawn(IContext context, GameMapDefinition map, short spawnNumber, MonsterDefinition monsterDefinition, byte x, byte y, SpawnTrigger spawnTrigger) { var area = context.CreateNew(); diff --git a/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs b/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs index 94ab1f48cb..d5d0356c75 100644 --- a/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs +++ b/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs @@ -19,8 +19,9 @@ public static class GameContextTestHelper /// /// Additional plugin configurations which should be applied, e.g. to deactivate specific plugins. /// The drop generator to use, e.g. to test reward item drops. Defaults to , which never generates anything. + /// The maximum character level of the configuration. Needs to be greater than 0 for tests which grant experience, since experience gain stops at the maximum level. /// The game context with MuHelperFeaturePlugIn configured. - public static IGameContext CreateGameContext(IEnumerable? additionalPlugInConfigurations = null, IDropGenerator? dropGenerator = null) + public static IGameContext CreateGameContext(IEnumerable? additionalPlugInConfigurations = null, IDropGenerator? dropGenerator = null, short maximumLevel = 0) { dropGenerator ??= NullDropGenerator.Instance; var contextProvider = new InMemoryPersistenceContextProvider(); @@ -34,7 +35,7 @@ public static IGameContext CreateGameContext(IEnumerable? a gameConfig.RecoveryInterval = int.MaxValue; gameConfig.MaximumInventoryMoney = int.MaxValue; gameConfig.ItemDropDuration = TimeSpan.FromMinutes(1); - gameConfig.MaximumLevel = 400; + gameConfig.MaximumLevel = maximumLevel; var mapInitializer = new MapInitializer(gameConfig, new NullLogger(), NullDropGenerator.Instance, null); var plugInConfigurations = new List diff --git a/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs b/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs index 86eead4e5f..04c81dd43a 100644 --- a/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs +++ b/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs @@ -74,8 +74,12 @@ public async ValueTask FinishesWhenTooFewPlayersRemainAsync() await illusionTemple.Map.RemoveAsync(players[0]).ConfigureAwait(false); + // The leaving player is gone from the event... Assert.That(illusionTemple.PlayerCount, Is.LessThan(2)); + // ...and the match is actually finished, rather than continuing with a single participant. + await WaitUntilAsync(() => illusionTemple.State != MiniGameState.Playing).ConfigureAwait(false); + Assert.That(illusionTemple.State, Is.Not.EqualTo(MiniGameState.Playing)); } /// @@ -415,8 +419,12 @@ private static async ValueTask CreatePlayerAsync(IGameContext gameContex private static async ValueTask CreateContextAsync(IGameContext gameContext, MiniGameDefinition definition) { - var context = await gameContext.GetMiniGameAsync(definition, null!).ConfigureAwait(false); - return (IllusionTempleContext)context; + var context = (IllusionTempleContext)await gameContext.GetMiniGameAsync(definition, null!).ConfigureAwait(false); + + // These tests drive the game start directly - without this, every one of them would sit out the + // real preparation countdown before the match begins. + context.PreparationDuration = TimeSpan.Zero; + return context; } private static MiniGameDefinition CreateDefinition(IGameContext gameContext, int minimumPlayerCount, int maximumPlayerCount = 10, bool includeItemReward = false) @@ -540,7 +548,9 @@ private static MonsterSpawnArea CreateSpawn(GameMapDefinition map, short number, private static IGameContext CreateGameContext(IDropGenerator? dropGenerator = null) { - return GameContextTestHelper.CreateGameContext(dropGenerator: dropGenerator); + // The maximum level has to be set, since experience gain is a no-op above it - the reward tests + // wouldn't see any experience otherwise. + return GameContextTestHelper.CreateGameContext(dropGenerator: dropGenerator, maximumLevel: 400); } private static async ValueTask InvokePrivateAsync(object target, string methodName, params object?[] args) From a7d7cf95fa3d6d2ec55a2f24c51e42781746d481 Mon Sep 17 00:00:00 2001 From: bulgarashi Date: Mon, 24 Aug 2026 21:38:26 +0200 Subject: [PATCH 10/11] Fix the class in the result screen and the reopening of the entry dialog Character class: The result screen showed the same wrong class for every player. A packet capture confirmed the server sends correct, distinct values, so the client decodes them differently than assumed: it reads the class line from the lower nibble (0 Dark Wizard, 1 Dark Knight, 2 Fairy Elf, 3 Magic Gladiator, 4 Dark Lord, 5 Summoner, 6 Rage Fighter) and the evolution step from the upper one, while the internal numbering packs both the other way around. Two players of different lines therefore collapsed onto the same entry whenever the sent values happened to share their lower nibble - both were shown as the Magic Gladiator line's master class. The conversion is inverted accordingly and verified on a live client. Uninitialized packet bytes: Each player entry carries three alignment bytes which are never written, and the pipe buffer isn't zeroed, so leftovers of previous packets went out on the wire. The buffer is cleared before writing now. Entry dialog couldn't be opened a second time: The player state is only reset back to EnteredWorld after the dialog has been shown, so a failure while querying the temple user counts left the player stuck in NpcDialogOpened - and opening the dialog requires exactly that transition. The reset moved into a finally block. The user count view plugin also missed the connection check every other view plugin has, which is one way that query could throw. Statue could stay locked for the rest of the match: Claiming the carrier slot before looking up the relic item meant an exception (e.g. a database without the item) left the slot claimed forever, and nobody could pick up the relic anymore. The claim is now released on every failure path, and a missing item definition is logged instead of thrown. Also reverted the WaitingRoom event state which was sent on entry: it was added on the assumption that the unused enum value belongs there, without evidence from the reference server, and it sent a new packet to the client before the player was even on the event map. Co-Authored-By: Claude Opus 5 --- .../MiniGames/IllusionTempleContext.cs | 87 +++++++++++-------- src/GameLogic/PlayerActions/TalkNpcAction.cs | 21 +++-- .../RemoteView/MiniGames/Extensions.cs | 22 +++++ .../IllusionTempleScoreTableViewPlugIn.cs | 8 +- .../IllusionTempleUserCountViewPlugin.cs | 7 +- 5 files changed, 100 insertions(+), 45 deletions(-) diff --git a/src/GameLogic/MiniGames/IllusionTempleContext.cs b/src/GameLogic/MiniGames/IllusionTempleContext.cs index f42972cf8f..feb54b2015 100644 --- a/src/GameLogic/MiniGames/IllusionTempleContext.cs +++ b/src/GameLogic/MiniGames/IllusionTempleContext.cs @@ -9,7 +9,6 @@ namespace MUnique.OpenMU.GameLogic.MiniGames; using MUnique.OpenMU.DataModel.Configuration.Items; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.NPC; -using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames; using MUnique.OpenMU.GameLogic.Views.Inventory; using MUnique.OpenMU.Pathfinding; @@ -296,23 +295,6 @@ protected override bool IsWinner(Player player) }; } - /// - /// - /// Tells the entering player's client that he's waiting for the match to start, so it can show the - /// event's waiting state. This is only sent to him, not to the other participants. - /// - public override async ValueTask TryEnterAsync(Player player) - { - var result = await base.TryEnterAsync(player).ConfigureAwait(false); - if (result == EnterResult.Success) - { - await player.InvokeViewPlugInAsync( - p => p.ChangeEventStateAsync((byte)this.Definition.GameLevel, IllusionTempleEventStatus.WaitingRoom)).ConfigureAwait(false); - } - - return result; - } - /// /// Handles a player talking to the stone statue (NPC 380) which holds the sacred relic. /// @@ -327,29 +309,49 @@ public async ValueTask TalkToNpcStoneStatueAsync(Player player) return; } - var item = player.PersistenceContext.CreateNew(); - item.Definition = player.GameContext.Configuration.Items.First(IsRelicDefinition); - - if (player.Inventory?.CheckInvSpace(item) is null) + try { - // Hand the claim back, and don't leave the unused item behind as an orphan entity. - this._relicCarrier = null; - await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false); - await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.InventoryFull)).ConfigureAwait(false); - return; - } + if (player.GameContext.Configuration.Items.FirstOrDefault(IsRelicDefinition) is not { } relicDefinition) + { + this.Logger.LogWarning( + "The sacred relic item (group {Group}, number {Number}) is missing in the configuration - the illusion temple can't be played.", + RelicItemGroup, + RelicItemNumber); + this._relicCarrier = null; + return; + } - await player.Inventory!.AddItemAsync(item).ConfigureAwait(false); - await player.InvokeViewPlugInAsync(p => p.ItemAppearAsync(item)).ConfigureAwait(false); - await this.ShowGoldenMessageAsync(nameof(PlayerMessage.IllusionTempleRelicPickedUpFormat), player.Name).ConfigureAwait(false); + var item = player.PersistenceContext.CreateNew(); + item.Definition = relicDefinition; - if (player.OpenedNpc is { } statue) + if (player.Inventory?.CheckInvSpace(item) is null) + { + // Hand the claim back, and don't leave the unused item behind as an orphan entity. + this._relicCarrier = null; + await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false); + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.InventoryFull)).ConfigureAwait(false); + return; + } + + await player.Inventory!.AddItemAsync(item).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.ItemAppearAsync(item)).ConfigureAwait(false); + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.IllusionTempleRelicPickedUpFormat), player.Name).ConfigureAwait(false); + + if (player.OpenedNpc is { } statue) + { + await statue.DisposeAsync().ConfigureAwait(false); + } + + await this.ForEachPlayerAsync(p => p.InvokeViewPlugInAsync( + vp => vp.ShowHolyItemRelicsAsync(player.Id, player.Name)).AsTask()).ConfigureAwait(false); + } + catch { - await statue.DisposeAsync().ConfigureAwait(false); + // Never keep the claim on an error - otherwise the statue would stay locked for the rest + // of the match, and nobody could pick up the relic anymore. + this._relicCarrier = null; + throw; } - - await this.ForEachPlayerAsync(p => p.InvokeViewPlugInAsync( - vp => vp.ShowHolyItemRelicsAsync(player.Id, player.Name)).AsTask()).ConfigureAwait(false); } /// @@ -875,6 +877,19 @@ protected async override ValueTask ShowScoreAsync(Player player) AddedExperience: this._grantedExperience.GetValueOrDefault(entry.Key))) .ToList(); + if (this.Logger.IsEnabled(LogLevel.Debug)) + { + foreach (var result in results) + { + this.Logger.LogDebug( + "Illusion temple result row: name={Name}, team={Team}, internal class number={ClassNumber}, experience={Experience}", + result.Name, + result.Team, + result.CharacterClass, + result.AddedExperience); + } + } + await player.InvokeViewPlugInAsync(p => p.ShowScoreTableAsync(this.Score.AlliedForcesScore, this.Score.IllusionForcesScore, results)).ConfigureAwait(false); await base.ShowScoreAsync(player).ConfigureAwait(false); } diff --git a/src/GameLogic/PlayerActions/TalkNpcAction.cs b/src/GameLogic/PlayerActions/TalkNpcAction.cs index 8e3d40dc2d..ecf06d16c3 100644 --- a/src/GameLogic/PlayerActions/TalkNpcAction.cs +++ b/src/GameLogic/PlayerActions/TalkNpcAction.cs @@ -151,14 +151,21 @@ private async ValueTask ShowDialogOfOpenedNpcAsync(Player player) await player.InvokeViewPlugInAsync(p => p.OpenNpcWindowAsync(npcStats.NpcWindow)).ConfigureAwait(false); break; case NpcWindow.IllusionTemple: - await player.InvokeViewPlugInAsync(p => p.OpenNpcWindowAsync(npcStats.NpcWindow)).ConfigureAwait(false); - await this.ShowIllusionTempleUserCountsAsync(player).ConfigureAwait(false); + try + { + await player.InvokeViewPlugInAsync(p => p.OpenNpcWindowAsync(npcStats.NpcWindow)).ConfigureAwait(false); + await this.ShowIllusionTempleUserCountsAsync(player).ConfigureAwait(false); + } + finally + { + // The client doesn't tell the server when this window is closed, so the state is reset + // right away - otherwise the player would be stuck in the NpcDialogOpened state and + // couldn't open the window a second time. That has to happen even when showing the + // dialog failed, for the same reason. The npc itself stays assigned, so that the + // entry can still report its refusals as a message of the npc. + await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false); + } - // The client doesn't tell the server when this window is closed, so the state is reset - // right away - otherwise the player would be stuck in the NpcDialogOpened state and - // couldn't open the window a second time. The npc itself stays assigned, so that the - // entry can still report its refusals as a message of the npc. - await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false); break; default: await player.InvokeViewPlugInAsync(p => p.OpenNpcWindowAsync(npcStats.NpcWindow)).ConfigureAwait(false); diff --git a/src/GameServer/RemoteView/MiniGames/Extensions.cs b/src/GameServer/RemoteView/MiniGames/Extensions.cs index 02cce6a979..6a20474ad9 100644 --- a/src/GameServer/RemoteView/MiniGames/Extensions.cs +++ b/src/GameServer/RemoteView/MiniGames/Extensions.cs @@ -85,4 +85,26 @@ public static byte ToIllusionTempleEnterResult(this EnterResult enterResult) { return enterResult == EnterResult.Success ? (byte)0 : (byte)1; } + + /// + /// Converts the internal character class number into the number the game client expects in the + /// illusion temple result packet, so the score board shows the right class next to each player. + /// + /// The internal character class number. + /// The class number as the client knows it. + /// + /// The client reads the class line from the lower nibble (0 Dark Wizard, 1 Dark Knight, 2 Fairy + /// Elf, 3 Magic Gladiator, 4 Dark Lord, 5 Summoner, 6 Rage Fighter) and the evolution step from + /// the upper one (0 base class, 2 second class, 3 master class), while the internal numbering + /// packs both the other way around. This was confirmed on a live client: two players of different + /// lines were shown as the same class, because the values sent at the time happened to share their + /// lower nibble. + /// + public static byte ToIllusionTempleCharacterClass(this byte characterClassNumber) + { + // The internal number is line * 4 + evolution step. + var line = (byte)(characterClassNumber / 4); + var evolution = (byte)(characterClassNumber % 4); + return (byte)((evolution << 4) | (line & 0x0F)); + } } \ No newline at end of file diff --git a/src/GameServer/RemoteView/MiniGames/IllusionTempleScoreTableViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/IllusionTempleScoreTableViewPlugIn.cs index fb6280e290..e9abf468cc 100644 --- a/src/GameServer/RemoteView/MiniGames/IllusionTempleScoreTableViewPlugIn.cs +++ b/src/GameServer/RemoteView/MiniGames/IllusionTempleScoreTableViewPlugIn.cs @@ -43,6 +43,12 @@ int Write() { var size = IllusionTempleResultRef.GetRequiredSize(results.Count); var span = connection.Output.GetSpan(size)[..size]; + + // The pipe buffer is reused and isn't zeroed, while each player entry contains three + // alignment bytes which are never written. Without clearing, leftovers of previous packets + // would go out on the wire and the client would read them as part of the entry. + span.Clear(); + var message = new IllusionTempleResultRef(span) { Team1Points = alliedForcesPoints, @@ -57,7 +63,7 @@ int Write() entry.Name = name; entry.MapNumber = mapNumber; entry.Team = (byte)team; - entry.Class = characterClass; + entry.Class = characterClass.ToIllusionTempleCharacterClass(); entry.AddedExperience = (uint)Math.Max(0, addedExperience); i++; } diff --git a/src/GameServer/RemoteView/MiniGames/IllusionTempleUserCountViewPlugin.cs b/src/GameServer/RemoteView/MiniGames/IllusionTempleUserCountViewPlugin.cs index 962f123710..c3eea9d46d 100644 --- a/src/GameServer/RemoteView/MiniGames/IllusionTempleUserCountViewPlugin.cs +++ b/src/GameServer/RemoteView/MiniGames/IllusionTempleUserCountViewPlugin.cs @@ -35,13 +35,18 @@ public IllusionTempleUserCountViewPlugIn(RemotePlayer player) /// public async ValueTask ShowUserCountAsync(IReadOnlyList userCounts) { + if (this._player.Connection is not { } connection) + { + return; + } + // The packet holds one byte per temple, so a missing or oversized count is reported as the // closest value the client can display, instead of throwing or wrapping around. byte Count(int index) => index < userCounts.Count ? (byte)Math.Clamp(userCounts[index], 0, byte.MaxValue) : (byte)0; - await this._player.Connection.SendIllusionTempleUserCountAsync( + await connection.SendIllusionTempleUserCountAsync( Count(0), Count(1), Count(2), From 129ec67d80cffc1edd6d29590896431b562e6159 Mon Sep 17 00:00:00 2001 From: bulgarashi Date: Mon, 24 Aug 2026 22:03:53 +0200 Subject: [PATCH 11/11] small fix --- src/Persistence/Initialization/Updates/UpdateVersion.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Persistence/Initialization/Updates/UpdateVersion.cs b/src/Persistence/Initialization/Updates/UpdateVersion.cs index 328bc5b44d..745fa78b0c 100644 --- a/src/Persistence/Initialization/Updates/UpdateVersion.cs +++ b/src/Persistence/Initialization/Updates/UpdateVersion.cs @@ -530,6 +530,7 @@ public enum UpdateVersion /// ConfigureCastleSiegeParticipation = 104, + /// /// The version of the . /// IllusionTempleData = 105,