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