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