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/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 | | 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..feb54b2015 --- /dev/null +++ b/src/GameLogic/MiniGames/IllusionTempleContext.cs @@ -0,0 +1,1129 @@ +// +// 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.DataModel.Configuration.Items; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.Views.Inventory; +using MUnique.OpenMU.Pathfinding; + +/// +/// 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; + + /// + /// The item group of the sacred relic ("Cursed Castle Water"). + /// + 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 + /// 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 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 readonly 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; + + /// + /// 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(); + + /// + /// 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; + + /// + /// + /// 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; + + /// + /// + /// 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 + /// 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) + { + // 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; + } + + try + { + 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; + } + + var item = player.PersistenceContext.CreateNew(); + item.Definition = relicDefinition; + + 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 + { + // 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; + } + } + + /// + /// 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 => IsRelicDefinition(i.Definition)); + + 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(); + + 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.IsEventRunning || !player.IsAlive || !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); + this.NotifyWhenSkillEffectEnds(magicEffect, OrderOfProtectionSkillNumber, player); + 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 + || !this.IsHostileTarget(player, target) + || 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); + this.NotifyWhenSkillEffectEnds(magicEffect, RestraintSkillNumber, target); + 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 + || !this.IsHostileTarget(player, target) + || 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]; + 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); + } + + // 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); + + // 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(this.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, 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) + { + if (args.Object is Player player) + { + await this.DropRelicIfCarriedByAsync(player).ConfigureAwait(false); + + if (player.Party is { } party) + { + await party.KickMySelfAsync(player).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 => IsRelicDefinition(i.Definition)); + + 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 + || !IsRelicDefinition(item.Item.Definition)) + { + 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 + && IsRelicDefinition(droppedItem.Item.Definition)) + { + 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(); + + 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); + } + + /// + /// 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 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.IsWinner(player)) + { + 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); + } + } + + await this.RemoveRelicFromInventoryAsync(player).ConfigureAwait(false); + await player.WarpToSafezoneAsync().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) + { + var remainingSeconds = (int)this.RemainingTime.TotalSeconds; + foreach (var winner in finishers.Where(this.IsWinner)) + { + var rank = this._winnerRanks.Count + 1; + 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(); + + // 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); + } + } + + // 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 + // 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); + } + + /// + /// 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) + { + if (target is null || ReferenceEquals(target, player)) + { + return false; + } + + if (target is not Player targetPlayer) + { + // 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) + { + 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..403597a4c8 --- /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.MiniGames; + +/// +/// 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..266f39c522 100644 --- a/src/GameLogic/MiniGames/MiniGameContext.cs +++ b/src/GameLogic/MiniGames/MiniGameContext.cs @@ -137,16 +137,17 @@ 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. /// /// 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)) { @@ -202,6 +203,13 @@ public virtual bool IsSkillAllowed(Skill skill, Player attacker, IAttackable tar return true; } + /// + /// 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; + /// public override string ToString() { @@ -558,7 +566,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 +754,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 +890,38 @@ private async ValueTask MovePlayersToSafezoneAsync() } } - private bool DoesRewardApply(Player player, int playerRank, MiniGameReward reward) + /// + /// 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) { @@ -898,26 +948,22 @@ private bool DoesRewardApply(Player player, int playerRank, MiniGameReward rewar 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/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..afb2f25277 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, nameof(PlayerMessage.MiniGameEnterFailed)).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, nameof(PlayerMessage.MiniGameCharacterLevelTooLowFormat), minLevel).ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.CharacterLevelTooLow)).ConfigureAwait(false); return; } if (characterLevel > maxLevel) { + 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, 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, 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, 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, nameof(PlayerMessage.MiniGameNotDuringGuildWar)).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, nameof(PlayerMessage.MiniGameNotOpenFormat), miniGameDefinition.Name).ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.NotOpen)).ConfigureAwait(false); return; } @@ -140,6 +149,28 @@ 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 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); + } + 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..ecf06d16c3 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,22 @@ 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 + 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; + } + } else { await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.TalkingNotImplementedFormat), npcStats.Number, npcStats.Designation).ConfigureAwait(false); @@ -132,6 +149,23 @@ private async ValueTask ShowDialogOfOpenedNpcAsync(Player player) case NpcWindow.ChaosMachine: case NpcWindow.RemoveJohOption: await player.InvokeViewPlugInAsync(p => p.OpenNpcWindowAsync(npcStats.NpcWindow)).ConfigureAwait(false); + break; + case NpcWindow.IllusionTemple: + 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); + } + break; default: await player.InvokeViewPlugInAsync(p => p.OpenNpcWindowAsync(npcStats.NpcWindow)).ConfigureAwait(false); @@ -144,6 +178,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/ChatCommands/StartIllusionTempleEventChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/StartIllusionTempleEventChatCommandPlugIn.cs new file mode 100644 index 0000000000..39a965d5b6 --- /dev/null +++ b/src/GameLogic/PlugIns/ChatCommands/StartIllusionTempleEventChatCommandPlugIn.cs @@ -0,0 +1,39 @@ +// +// 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 startit 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) + { + 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/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/PlugIns/PeriodicTasks/IllusionTempleGameServerState.cs b/src/GameLogic/PlugIns/PeriodicTasks/IllusionTempleGameServerState.cs new file mode 100644 index 0000000000..54daa47ae2 --- /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 for an illusion temple 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/PlayerMessage.Designer.cs b/src/GameLogic/Properties/PlayerMessage.Designer.cs index abf9e627bd..9a21c03d7a 100644 --- a/src/GameLogic/Properties/PlayerMessage.Designer.cs +++ b/src/GameLogic/Properties/PlayerMessage.Designer.cs @@ -1508,7 +1508,133 @@ 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 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!. + /// + 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..cd3eb45ebb 100644 --- a/src/GameLogic/Properties/PlayerMessage.resx +++ b/src/GameLogic/Properties/PlayerMessage.resx @@ -423,6 +423,48 @@ 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! + + + 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! + {0} has destroyed the Crystal Statue! 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/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/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 diff --git a/src/GameServer/RemoteView/MiniGames/Extensions.cs b/src/GameServer/RemoteView/MiniGames/Extensions.cs index 9ce81ee8ab..6a20474ad9 100644 --- a/src/GameServer/RemoteView/MiniGames/Extensions.cs +++ b/src/GameServer/RemoteView/MiniGames/Extensions.cs @@ -70,4 +70,41 @@ 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 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/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..e9abf468cc --- /dev/null +++ b/src/GameServer/RemoteView/MiniGames/IllusionTempleScoreTableViewPlugIn.cs @@ -0,0 +1,76 @@ +// +// 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]; + + // 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, + 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.ToIllusionTempleCharacterClass(); + 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..c3eea9d46d --- /dev/null +++ b/src/GameServer/RemoteView/MiniGames/IllusionTempleUserCountViewPlugin.cs @@ -0,0 +1,57 @@ +// +// 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) + { + 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 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/Updates/IllusionTempleDataUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/IllusionTempleDataUpdatePlugIn.cs new file mode 100644 index 0000000000..a8bfcb7cff --- /dev/null +++ b/src/Persistence/Initialization/Updates/IllusionTempleDataUpdatePlugIn.cs @@ -0,0 +1,483 @@ +// +// 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 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. + /// + 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; + } + + this.RemoveObsoleteStatueSpawns(map); + + 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); + } + } + } + + /// + /// 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(); + 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..745fa78b0c 100644 --- a/src/Persistence/Initialization/Updates/UpdateVersion.cs +++ b/src/Persistence/Initialization/Updates/UpdateVersion.cs @@ -529,4 +529,9 @@ public enum UpdateVersion /// The version of the . /// ConfigureCastleSiegeParticipation = 104, + + /// + /// The version of the . + /// + IllusionTempleData = 105, } 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/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); 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. } /// 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. /// diff --git a/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs b/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs index f44ac74eee..d5d0356c75 100644 --- a/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs +++ b/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs @@ -18,9 +18,12 @@ 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 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) + public static IGameContext CreateGameContext(IEnumerable? additionalPlugInConfigurations = null, IDropGenerator? dropGenerator = null, short maximumLevel = 0) { + dropGenerator ??= NullDropGenerator.Instance; var contextProvider = new InMemoryPersistenceContextProvider(); var context = contextProvider.CreateNewContext(); var gameConfig = context.CreateNew(); @@ -32,6 +35,7 @@ public static IGameContext CreateGameContext(IEnumerable? a gameConfig.RecoveryInterval = int.MaxValue; gameConfig.MaximumInventoryMoney = int.MaxValue; gameConfig.ItemDropDuration = TimeSpan.FromMinutes(1); + gameConfig.MaximumLevel = maximumLevel; var mapInitializer = new MapInitializer(gameConfig, new NullLogger(), NullDropGenerator.Instance, null); var plugInConfigurations = new List @@ -49,7 +53,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..04c81dd43a --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/IllusionTempleContextTest.cs @@ -0,0 +1,616 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using System.Diagnostics.CodeAnalysis; +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); + + // 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)); + } + + /// + /// 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); + 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); + 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; + } + + /// + /// 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. + /// + [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); + 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. 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); + } + + 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 = (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) + { + 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. + /// + [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 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 + { + Id = new Guid(0, 0, 0, 0, 0, 0, 0, 0, 0, (byte)map.Number, (byte)number), + GameMap = map, + MonsterDefinition = monsterDefinition, + Quantity = 1, + SpawnTrigger = trigger, + X1 = x, + X2 = x, + Y1 = y, + Y2 = y, + }; + } + + private static IGameContext CreateGameContext(IDropGenerator? dropGenerator = null) + { + // 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) + { + 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); + } + + /// + /// 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. + /// + [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) + { + 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 = 5000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMilliseconds); + while (!condition() && DateTime.UtcNow < deadline) + { + await Task.Delay(10).ConfigureAwait(false); + } + } + + private static async ValueTask AwaitResultAsync(object? result) + { + if (result is ValueTask valueTask) + { + await valueTask.ConfigureAwait(false); + } + else if (result is Task task) + { + await task.ConfigureAwait(false); + } + else + { + // The invoked method was synchronous (e.g. "async void") - there is nothing to await. + } + } +}