diff --git a/src/DataModel/Configuration/MiniGameType.cs b/src/DataModel/Configuration/MiniGameType.cs index de6fd1482..c3619b9c5 100644 --- a/src/DataModel/Configuration/MiniGameType.cs +++ b/src/DataModel/Configuration/MiniGameType.cs @@ -38,4 +38,9 @@ public enum MiniGameType /// The doppelganger event. /// Doppelganger, + + /// + /// The Kanturu Refinery Tower event. + /// + Kanturu, } \ No newline at end of file diff --git a/src/GameLogic/MiniGames/IKanturuEventViewPlugIn.cs b/src/GameLogic/MiniGames/IKanturuEventViewPlugIn.cs new file mode 100644 index 000000000..28f24f073 --- /dev/null +++ b/src/GameLogic/MiniGames/IKanturuEventViewPlugIn.cs @@ -0,0 +1,225 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.MiniGames; + +using MUnique.OpenMU.GameLogic.Views; + +/// +/// View plugin interface for Kanturu-specific server-to-client packets (0xD1 group). +/// Each method maps to one packet subcode. Names use Show... to signal that the game +/// logic is expressing intent, not dictating the transport mechanism. +/// +public interface IKanturuEventViewPlugIn : IViewPlugIn +{ + /// + /// Shows the Kanturu state info dialog to the player (packet 0xD1/0x00). + /// This opens the INTERFACE_KANTURU2ND_ENTERNPC dialog on the client, + /// showing the current event state, whether entry is possible, how many players + /// are inside, and how much time remains. + /// + /// Main event state. + /// + /// Protocol detail-state byte. Pass the raw byte value from the appropriate + /// detail-state enum (, + /// , or ). + /// + /// true if the Enter button should be enabled. + /// Number of players currently inside the event map. + /// + /// Remaining time. Standby: time until the event opens. + /// Tower: time the tower has been open. Otherwise . + /// + ValueTask ShowStateInfoAsync(KanturuState state, byte detailState, bool canEnter, int userCount, TimeSpan remainingTime); + + /// + /// Shows the result of the player's entry attempt (packet 0xD1/0x01). + /// On success the player has already been teleported before this is sent; + /// on failure the client displays the appropriate error popup. + /// + ValueTask ShowEnterResultAsync(KanturuEnterResult result); + + /// + /// Updates the client HUD and audio for a Maya battle phase transition (packet 0xD1/0x03). + /// + ValueTask ShowMayaBattleStateAsync(KanturuMayaDetailState detailState); + + /// + /// Updates the client HUD and audio for a Nightmare battle phase transition (packet 0xD1/0x03). + /// + ValueTask ShowNightmareStateAsync(KanturuNightmareDetailState detailState); + + /// + /// Updates the client HUD and audio for a Tower of Refinement phase transition (packet 0xD1/0x03). + /// When is + /// the client additionally reloads the success terrain file to remove the Elphis barrier visually. + /// + ValueTask ShowTowerStateAsync(KanturuTowerDetailState detailState); + + /// + /// Shows the battle outcome overlay to the player (packet 0xD1/0x04). + /// + ValueTask ShowBattleResultAsync(KanturuBattleResult result); + + /// + /// Starts the HUD countdown timer (packet 0xD1/0x05). + /// The client converts the value to seconds for display. + /// + ValueTask ShowTimeLimitAsync(TimeSpan timeLimit); + + /// + /// Updates the monster and user count numbers in the Kanturu HUD (packet 0xD1/0x07). + /// + ValueTask ShowMonsterUserCountAsync(int monsterCount, int userCount); + + /// + /// Triggers a Maya wide-area attack visual on the client (packet 0xD1/0x06). + /// This is purely cosmetic — damage is handled server-side by the monster's + /// AttackSkill. + /// + ValueTask ShowMayaWideAreaAttackAsync(KanturuMayaAttackType attackType); +} + +/// +/// Main state of the Kanturu event. Values are defined by the client's +/// KANTURU_STATE_TYPE enum and mapped to packet bytes in the view plugin layer. +/// +public enum KanturuState +{ + /// No active state. + None, + + /// Waiting for players to enter before the event starts. + Standby, + + /// Maya battle phase covering Phases 1–3 and their boss waves. + MayaBattle, + + /// Nightmare battle phase after all three Maya phases are cleared. + NightmareBattle, + + /// Tower of Refinement phase; opens after Nightmare is defeated. + Tower, + + /// Event has ended. + End, +} + +/// +/// Detail states for the phase, +/// matching KANTURU_MAYA_DIRECTION_TYPE on the client. +/// +public enum KanturuMayaDetailState +{ + /// No direction; HUD is hidden. + None, + + /// Maya notify/cinematic intro — camera pans to Maya room. + Notify, + + /// Phase 1 monster wave; HUD visible. + Monster1, + + /// Phase 1 boss: Maya Left Hand. + MayaLeft, + + /// Phase 2 monster wave; HUD visible. + Monster2, + + /// Phase 2 boss: Maya Right Hand. + MayaRight, + + /// Phase 3 monster wave; HUD visible. + Monster3, + + /// Phase 3 bosses: both Maya hands simultaneously. + BothHands, + + /// + /// Maya phase 3 end cycle — triggers the full Maya explosion and player-fall cinematic + /// (KANTURU_MAYA_DIRECTION_ENDCYCLE_MAYA3 = 16 on the client). + /// + EndCycleMaya3, +} + +/// +/// Detail states for the phase, +/// matching KANTURU_NIGHTMARE_DIRECTION_TYPE on the client. +/// +public enum KanturuNightmareDetailState +{ + /// No direction set. + None, + + /// Nightmare present but idle — not yet in active battle. + Idle, + + /// Nightmare intro animation playing. + Intro, + + /// Active battle — shows the HUD on the client. + Battle, + + /// Battle ended (Nightmare defeated). + End, +} + +/// +/// Detail states for the phase, +/// matching KANTURU_TOWER_STATE_TYPE on the client. +/// +public enum KanturuTowerDetailState +{ + /// No tower state. + None, + + /// + /// Tower is open after Nightmare's defeat. + /// Sending this triggers the client to reload EncTerrain<n>01.att + /// (the success terrain), which visually removes the Elphis barrier. + /// + Revitalization, + + /// Tower closing soon — client warns players. + Notify, + + /// Tower is closed. + Close, +} + +/// +/// Result of a Kanturu entry request. +/// +public enum KanturuEnterResult +{ + /// Entry failed (generic failure — level, missing pendant, event not open, etc.). + Failed, + + /// The player was successfully entered into the event. + Success, +} + +/// +/// Outcome of the Kanturu Refinery Tower battle. +/// +public enum KanturuBattleResult +{ + /// Event ended in failure; shows the Failure_kantru.tga overlay. + Failure, + + /// Nightmare was defeated; shows the Success_kantru.tga overlay. + Victory, +} + +/// +/// Visual type of a Maya wide-area attack broadcast. +/// +public enum KanturuMayaAttackType +{ + /// Stone-storm effect (MODEL_STORM3 + falling debris). + Storm, + + /// Stone-rain effect (MODEL_MAYASTONE projectiles). + Rain, +} diff --git a/src/GameLogic/MiniGames/KanturuContext.cs b/src/GameLogic/MiniGames/KanturuContext.cs new file mode 100644 index 000000000..69638494c --- /dev/null +++ b/src/GameLogic/MiniGames/KanturuContext.cs @@ -0,0 +1,982 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.MiniGames; + +using System.Threading; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameLogic.Views.World; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Pathfinding; + +/// +/// The context of a Kanturu Refinery Tower event game. +/// +/// +/// The Kanturu event progresses through sequential phases: +/// Phase 1: Kill 30 Blade Hunters + 10 Dreadfear → Maya (Left Hand) spawns → kill her. +/// Phase 2: Kill 30 Blade Hunters + 10 Dreadfear → Maya (Right Hand) spawns → kill her. +/// Phase 3: Kill 10 Dreadfear + 10 Twin Tale → Both Maya hands spawn → kill both. +/// Nightmare Prep: Kill 15 Genocider + 15 Dreadfear + 15 Persona → Nightmare spawns. +/// Nightmare: At 75/50/25% HP Nightmare teleports and regains full HP. +/// Victory: Kill Nightmare → Elphis barrier opens → Tower of Refinement. +/// Between phases there is a 2-minute standby period. +/// Players who die are respawned at Kanturu Relics (handled by map's SafezoneMap setting). +/// +public sealed class KanturuContext : MiniGameContext +{ + // Monster definition numbers + private const short MayaBodyNumber = 364; + private const short MayaLeftHandNumber = 362; + private const short MayaRightHandNumber = 363; + private const short NightmareNumber = 361; + private const short BladeHunterNumber = 354; + private const short DreadfearNumber = 360; + private const short TwinTaleNumber = 359; + private const short GenociderNumber = 357; + private const short PersonaNumber = 358; + + // Wave numbers matching MonsterSpawnArea.WaveNumber in KanturuEvent.cs + private const byte WaveMayaAppear = 0; // Maya body rises at battle start + private const byte WavePhase1Monsters = 1; + private const byte WavePhase1Boss = 2; + private const byte WavePhase2Monsters = 3; + private const byte WavePhase2Boss = 4; + private const byte WavePhase3Monsters = 5; + private const byte WavePhase3Bosses = 6; + private const byte WaveNightmarePrep = 7; + private const byte WaveNightmare = 8; + + // Skill numbers for Nightmare special attacks. + // These map directly to client-side AT_SKILL_* enum values and drive the animation selection + // on the Nightmare model (MODEL_DARK_SKULL_SOLDIER_5) in GM_Kanturu_3rd.cpp. + // Inferno (#14, AT_SKILL_INFERNO): ATTACK4 — Inferno explosion + 2×MODEL_CIRCLE effects. + private const short NightmareInfernoskillNumber = 14; + + // Nightmare phase teleport positions within the Nightmare Zone (X:75-88, Y:97-143) + // Phase 1 = initial spawn at (78, 143) defined in KanturuEvent.cs + private static readonly Point NightmarePhase2Pos = new(82, 130); + private static readonly Point NightmarePhase3Pos = new(76, 115); + private static readonly Point NightmarePhase4Pos = new(85, 100); + + // Elphis barrier area — cells that are NoGround (value 8) in the .att file and block + // the path from the Nightmare zone to the Elpis NPC area (Y~177). + // Confirmed via Terrain39.att analysis: entire X=73-90, Y=144-180 column is NoGround. + // OpenMU reads WalkMap[x,y] = false for these cells by default; we override to true + // after Nightmare is defeated so players can walk to Elpis. + private static readonly (byte StartX, byte StartY, byte EndX, byte EndY)[] ElphisBarrierAreas = + [ + (73, 144, 90, 195), + ]; + + private readonly IMapInitializer _mapInitializer; + private readonly TimeSpan _towerOfRefinementDuration; + + // volatile: _phase is written by the game-loop task (RunKanturuGameLoopAsync) and read + // by OnMonsterDied, which runs on the thread-pool via the monster's death event. + // volatile ensures the death handler always sees the latest phase assignment without + // a memory-barrier instruction on every increment in the hot path. + private volatile KanturuPhase _phase = KanturuPhase.Open; + private int _waveKillCount; + private int _waveKillTarget; + private TaskCompletionSource _phaseComplete = new(TaskCreationOptions.RunContinuationsAsynchronously); + + // volatile: written by the game loop on victory/defeat and read by GameEndedAsync, + // which can be called from a different thread when the game ends by timeout. + private volatile bool _isVictory; + + // Interlocked flag: 0 = not yet opened, 1 = opened (or in progress). + // Ensures the barrier is opened exactly once regardless of whether the trigger + // comes from OnMonsterDied (fire-and-forget) or the game loop. + private int _barrierOpened; + + // Nightmare HP-phase tracking + private Monster? _nightmareMonster; + private int _nightmarePhase; + + // True while ExecuteNightmareTeleportAsync is in progress. + // Prevents MonitorNightmareHpAsync from re-triggering a teleport during the animation window. + private volatile bool _nightmareTeleporting; + + // True while inter-phase standby is running — suppresses Maya wide-area attacks so + // Maya stays visually idle (no storm/stone-rain effects) during the break between phases. + private volatile bool _mayaAttacksPaused; + + /// + /// Gets the current Kanturu main state (the last state sent via 0xD1/0x03). + /// The Gateway NPC plugin reads this to populate the 0xD1/0x00 StateInfo dialog + /// while the event is in progress. + /// + public KanturuState CurrentKanturuState { get; private set; } = KanturuState.MayaBattle; + + /// + /// Gets the current Kanturu detail state byte (the last detailState sent via 0xD1/0x03). + /// Passed directly to as the + /// protocol-level byte, since detail-state enums differ per main state. + /// + public byte CurrentKanturuDetailState { get; private set; } + + private enum KanturuPhase + { + Open, + Phase1Monsters, + Phase1Boss, + Phase2Monsters, + Phase2Boss, + Phase3Monsters, + Phase3Bosses, + NightmarePrep, + NightmareActive, + Ended, + } + + /// + /// 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. + /// + /// How long the Tower of Refinement stays open after Nightmare is defeated. + /// Defaults to 1 hour if not specified. + /// + public KanturuContext( + MiniGameMapKey key, + MiniGameDefinition definition, + IGameContext gameContext, + IMapInitializer mapInitializer, + TimeSpan towerOfRefinementDuration = default) + : base(key, definition, gameContext, mapInitializer) + { + this._mapInitializer = mapInitializer; + this._towerOfRefinementDuration = towerOfRefinementDuration == default + ? TimeSpan.FromHours(1) + : towerOfRefinementDuration; + } + + /// + protected override async ValueTask OnGameStartAsync(ICollection players) + { + await base.OnGameStartAsync(players).ConfigureAwait(false); + + // Maya rises from the depths when the battle begins. + await this._mapInitializer.InitializeNpcsOnWaveStartAsync(this.Map, this, WaveMayaAppear).ConfigureAwait(false); + + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.KanturuMayaRises)).ConfigureAwait(false); + + _ = Task.Run(() => this.RunKanturuGameLoopAsync(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 // Avoid async void methods + { + try + { + base.OnMonsterDied(sender, e); + + if (sender is not Monster monster) + { + return; + } + + var num = (short)monster.Definition.Number; + var phase = this._phase; + bool complete; + + switch (phase) + { + case KanturuPhase.Phase1Monsters when num is BladeHunterNumber or DreadfearNumber: + { + var killed = Interlocked.Increment(ref this._waveKillCount); + complete = killed == this._waveKillTarget; + await this.ShowMonsterUserCountAsync(Math.Max(0, this._waveKillTarget - killed), this.PlayerCount).ConfigureAwait(false); + break; + } + + case KanturuPhase.Phase1Boss when num == MayaLeftHandNumber: + { + complete = true; + await this.ShowMonsterUserCountAsync(0, this.PlayerCount).ConfigureAwait(false); + break; + } + + case KanturuPhase.Phase2Monsters when num is BladeHunterNumber or DreadfearNumber: + { + var killed = Interlocked.Increment(ref this._waveKillCount); + complete = killed == this._waveKillTarget; + await this.ShowMonsterUserCountAsync(Math.Max(0, this._waveKillTarget - killed), this.PlayerCount).ConfigureAwait(false); + break; + } + + case KanturuPhase.Phase2Boss when num == MayaRightHandNumber: + { + complete = true; + await this.ShowMonsterUserCountAsync(0, this.PlayerCount).ConfigureAwait(false); + break; + } + + case KanturuPhase.Phase3Monsters when num is DreadfearNumber or TwinTaleNumber: + { + var killed = Interlocked.Increment(ref this._waveKillCount); + complete = killed == this._waveKillTarget; + await this.ShowMonsterUserCountAsync(Math.Max(0, this._waveKillTarget - killed), this.PlayerCount).ConfigureAwait(false); + break; + } + + case KanturuPhase.Phase3Bosses when num is MayaLeftHandNumber or MayaRightHandNumber: + { + var killed = Interlocked.Increment(ref this._waveKillCount); + complete = killed == this._waveKillTarget; + await this.ShowMonsterUserCountAsync(Math.Max(0, this._waveKillTarget - killed), this.PlayerCount).ConfigureAwait(false); + break; + } + + case KanturuPhase.NightmarePrep when num is GenociderNumber or DreadfearNumber or PersonaNumber: + { + var killed = Interlocked.Increment(ref this._waveKillCount); + complete = killed == this._waveKillTarget; + await this.ShowMonsterUserCountAsync(Math.Max(0, this._waveKillTarget - killed), this.PlayerCount).ConfigureAwait(false); + break; + } + + case KanturuPhase.NightmareActive when num is GenociderNumber or DreadfearNumber or PersonaNumber: + { + // A guardian died while Nightmare is alive — update the HUD counter but do NOT + // advance the phase. _waveKillTarget=46 (45 guardians + 1 Nightmare), so + // remaining = 46 − killed still accounts for Nightmare being alive. + var killed = Interlocked.Increment(ref this._waveKillCount); + await this.ShowMonsterUserCountAsync(Math.Max(0, this._waveKillTarget - killed), this.PlayerCount).ConfigureAwait(false); + complete = false; + break; + } + + case KanturuPhase.NightmareActive when num == NightmareNumber: + complete = true; + // Open the barrier immediately from the death event. + // Do NOT wait for the game loop — it may be interrupted by + // GameEndedToken cancellation before reaching OpenElphisBarrierAsync. + await this.OpenElphisBarrierAsync().ConfigureAwait(false); + break; + + default: + // Diagnostic: if Nightmare dies outside the expected phase, warn. + if (num == NightmareNumber) + { + this.Logger.LogWarning( + "Kanturu: Nightmare died but _phase={Phase} (expected NightmareActive). Barrier NOT opened.", + phase); + await this.ForEachPlayerAsync(p => + p.InvokeViewPlugInAsync(v => + v.ShowMessageAsync( + $"[Kanturu] Nightmare died out of phase! Phase={phase}", + MessageType.BlueNormal)).AsTask()).ConfigureAwait(false); + } + + complete = false; + break; + } + + if (complete) + { + this._phaseComplete.TrySetResult(); + } + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Unexpected error when handling a monster death."); + } + } + + /// + protected override async ValueTask GameEndedAsync(ICollection finishers) + { + await this.ShowGoldenMessageAsync( + this._isVictory + ? nameof(PlayerMessage.KanturuVictory) + : nameof(PlayerMessage.KanturuDefeat)).ConfigureAwait(false); + + // On defeat show the Failure_kantru.tga overlay. + // On victory the Success_kantru.tga and Tower state are sent from OpenElphisBarrierAsync. + if (!this._isVictory) + { + await this.ForEachPlayerAsync(player => + player.InvokeViewPlugInAsync(p => + p.ShowBattleResultAsync(KanturuBattleResult.Failure)).AsTask()).ConfigureAwait(false); + } + + await base.GameEndedAsync(finishers).ConfigureAwait(false); + } + + private async Task RunKanturuGameLoopAsync(CancellationToken ct) + { + try + { + // Maya "notify" cinematic — camera pans to Maya, Maya body rises from below. + // Must be sent first so the client camera is in position before the first wave. + await this.ShowMayaBattleStateToAllAsync(KanturuMayaDetailState.Notify).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(3), ct).ConfigureAwait(false); + + // Start the Maya wide-area attack visual loop for the duration of all Maya phases. + // The loop broadcasts 0xD1/0x06 every 15 s, alternating storm and stone-rain. + using var mayaAttackCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _ = Task.Run(() => this.RunMayaWideAreaAttacksAsync(mayaAttackCts.Token), mayaAttackCts.Token); + + // Phase 1: wave of monsters — 10-minute timer covers wave + boss. + await this.ShowMayaBattleStateToAllAsync(KanturuMayaDetailState.Monster1).ConfigureAwait(false); + await this.ShowTimeLimitToAllAsync(TimeSpan.FromMinutes(10)).ConfigureAwait(false); + await this.AdvancePhaseAsync(KanturuPhase.Phase1Monsters, WavePhase1Monsters, 40, + PlayerMessage.KanturuPhase1Start, ct).ConfigureAwait(false); + + // Phase 1: Maya Left Hand boss + await this.ShowMayaBattleStateToAllAsync(KanturuMayaDetailState.MayaLeft).ConfigureAwait(false); + await this.AdvancePhaseAsync(KanturuPhase.Phase1Boss, WavePhase1Boss, 1, + PlayerMessage.KanturuMayaLeftHandAppeared, ct).ConfigureAwait(false); + + // Standby between phases + await this.SendStandbyMessageAsync(PlayerMessage.KanturuPhase1Cleared, ct).ConfigureAwait(false); + + // Phase 2: wave of monsters — fresh 10-minute timer. + await this.ShowMayaBattleStateToAllAsync(KanturuMayaDetailState.Monster2).ConfigureAwait(false); + await this.ShowTimeLimitToAllAsync(TimeSpan.FromMinutes(10)).ConfigureAwait(false); + await this.AdvancePhaseAsync(KanturuPhase.Phase2Monsters, WavePhase2Monsters, 40, + PlayerMessage.KanturuPhase2Start, ct).ConfigureAwait(false); + + // Phase 2: Maya Right Hand boss + await this.ShowMayaBattleStateToAllAsync(KanturuMayaDetailState.MayaRight).ConfigureAwait(false); + await this.AdvancePhaseAsync(KanturuPhase.Phase2Boss, WavePhase2Boss, 1, + PlayerMessage.KanturuMayaRightHandAppeared, ct).ConfigureAwait(false); + + // Standby between phases + await this.SendStandbyMessageAsync(PlayerMessage.KanturuPhase2Cleared, ct).ConfigureAwait(false); + + // Phase 3: wave of monsters — fresh 10-minute timer. + await this.ShowMayaBattleStateToAllAsync(KanturuMayaDetailState.Monster3).ConfigureAwait(false); + await this.ShowTimeLimitToAllAsync(TimeSpan.FromMinutes(10)).ConfigureAwait(false); + await this.AdvancePhaseAsync(KanturuPhase.Phase3Monsters, WavePhase3Monsters, 20, + PlayerMessage.KanturuPhase3Start, ct).ConfigureAwait(false); + + // Phase 3: Both Maya bosses simultaneously + await this.ShowMayaBattleStateToAllAsync(KanturuMayaDetailState.BothHands).ConfigureAwait(false); + await this.AdvancePhaseAsync(KanturuPhase.Phase3Bosses, WavePhase3Bosses, 2, + PlayerMessage.KanturuBothMayaHandsAppeared, ct).ConfigureAwait(false); + + // Hide HUD during the loot window — same reason as inter-phase standby. + await this.ShowMayaBattleStateToAllAsync(KanturuMayaDetailState.None).ConfigureAwait(false); + + // 10-second loot window: players pick up drops from both Maya hands + // before the Nightmare transition cinematic begins. + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.KanturuMayaHandsFallen)).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(10), ct).ConfigureAwait(false); + + // Stop the Maya wide-area attack visuals before the Nightmare transition cinematic. + await mayaAttackCts.CancelAsync().ConfigureAwait(false); + + // Transition to Nightmare room: + // 1. TeleportToNightmareRoomAsync sends 0xD1/0x03 ENDCYCLE_MAYA3(16) which triggers + // the full cinematic (camera pan → Maya explosion → player falls through floor), + // waits ~10 s for it to complete, then MoveAsync teleports players to (79, 98). + // 2. NightmareBattle/Idle is sent AFTER teleport so the HUD change applies in + // the Nightmare zone, not the Maya battlefield. + await this.TeleportToNightmareRoomAsync(ct).ConfigureAwait(false); + await this.ShowNightmareStateToAllAsync(KanturuNightmareDetailState.Idle).ConfigureAwait(false); + + // Nightmare Prep: spawn guardians immediately, then spawn Nightmare after 3 seconds. + // No kill requirement — guardians fight alongside Nightmare. + await this.ShowTimeLimitToAllAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false); + Interlocked.Exchange(ref this._waveKillCount, 0); + this._waveKillTarget = 45; + this._phaseComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this._phase = KanturuPhase.NightmarePrep; + + await this._mapInitializer.InitializeNpcsOnWaveStartAsync(this.Map, this, WaveNightmarePrep).ConfigureAwait(false); + await this.ShowMonsterUserCountAsync(45, this.PlayerCount).ConfigureAwait(false); + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.KanturuNightmareGuardiansAppeared)).ConfigureAwait(false); + + await Task.Delay(TimeSpan.FromSeconds(3), ct).ConfigureAwait(false); + + // Nightmare boss with HP-phase teleports + await this.RunNightmarePhaseAsync(ct).ConfigureAwait(false); + + // Victory + this._isVictory = true; + this._phase = KanturuPhase.Ended; + + // Open the Elphis barrier (also sends the Success screen + Tower state). + // The fire-and-forget from OnMonsterDied already called this, but we call again + // as a fallback — the Interlocked guard ensures it only executes once. + await this.OpenElphisBarrierAsync().ConfigureAwait(false); + + // Tower of Refinement: keep the map open for a configurable period + await this.RunTowerOfRefinementAsync(ct).ConfigureAwait(false); + + this.FinishEvent(); + } + catch (OperationCanceledException) + { + // Game ended by timeout or external cancellation — treated as defeat. + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Unexpected error in Kanturu game loop."); + } + } + + /// + /// Runs the Nightmare phase. Nightmare teleports and recovers full HP at 75%, 50%, and 25% HP. + /// + private async Task RunNightmarePhaseAsync(CancellationToken ct) + { + this._nightmarePhase = 1; + this._nightmareMonster = null; + + // Do NOT reset _waveKillCount — it holds guardian kills from the 3-second prep window. + // _waveKillTarget = 46 (45 guardians + 1 Nightmare) so the HUD counter correctly + // reflects every remaining mob in the Nightmare zone, not just the boss. + this._waveKillTarget = 46; + this._phaseComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this._phase = KanturuPhase.NightmareActive; + + // Nightmare intro cinematic — camera moves to Nightmare zone and summons Nightmare. + // This uses detail=Intro(2) = KANTURU_NIGHTMARE_DIRECTION_NIGHTMARE on the client. + await this.ShowNightmareStateToAllAsync(KanturuNightmareDetailState.Intro).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(3), ct).ConfigureAwait(false); + + // Subscribe to ObjectAdded to capture the Nightmare reference as soon as it spawns. + var nightmareFound = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + async ValueTask OnObjectAdded((GameMap Map, ILocateable Object) args) + { + if (args.Object is Monster m && (short)m.Definition.Number == NightmareNumber) + { + nightmareFound.TrySetResult(m); + } + } + + this.Map.ObjectAdded += OnObjectAdded; + try + { + await this._mapInitializer.InitializeNpcsOnWaveStartAsync(this.Map, this, WaveNightmare) + .ConfigureAwait(false); + + // Wait up to 5 seconds for the spawn to register. + this._nightmareMonster = await nightmareFound.Task + .WaitAsync(TimeSpan.FromSeconds(5), ct) + .ConfigureAwait(false); + } + catch (TimeoutException) + { + this.Logger.LogWarning("Nightmare monster did not spawn within 5 seconds — HP phases disabled."); + } + finally + { + this.Map.ObjectAdded -= OnObjectAdded; + } + + // Switch to active battle state — shows Nightmare HUD on client (INTERFACE_KANTURU_INFO). + await this.ShowNightmareStateToAllAsync(KanturuNightmareDetailState.Battle).ConfigureAwait(false); + + // Show total remaining: alive guardians (45 − killed during prep) + 1 Nightmare. + var totalRemaining = Math.Max(1, this._waveKillTarget - this._waveKillCount); + await this.ShowMonsterUserCountAsync(totalRemaining, this.PlayerCount).ConfigureAwait(false); + + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.KanturuNightmareAppeared)).ConfigureAwait(false); + + // Start HP monitor and special-attack loop — both linked to the same CTS so they + // stop together the moment Nightmare dies or the game is cancelled. + using var hpCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var hpMonitor = Task.Run( + () => this.MonitorNightmareHpAsync(hpCts.Token), + hpCts.Token); + var specialAttacks = Task.Run( + () => this.RunNightmareSpecialAttacksAsync(hpCts.Token), + hpCts.Token); + + await this._phaseComplete.Task.WaitAsync(ct).ConfigureAwait(false); + + await hpCts.CancelAsync().ConfigureAwait(false); + try { await hpMonitor.ConfigureAwait(false); } + catch (OperationCanceledException) { /* expected on cancel */ } + try { await specialAttacks.ConfigureAwait(false); } + catch (OperationCanceledException) { /* expected on cancel */ } + } + + /// + /// Polls Nightmare's HP every second and triggers a phase teleport at 75/50/25%. + /// + private async Task MonitorNightmareHpAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false); + + // Do NOT check HP while a teleport is already in progress. + // ExecuteNightmareTeleportAsync restores Nightmare's HP as part of the sequence; + // reading HP mid-teleport would give a stale (low) value and re-trigger. + if (this._nightmareTeleporting) + { + continue; + } + + if (this._nightmareMonster is { IsAlive: true } nm) + { + var maxHp = nm.Attributes[Stats.MaximumHealth]; + var hpRatio = maxHp > 0 ? (float)nm.Health / maxHp : 1f; + + var targetPhase = hpRatio switch + { + < 0.25f => 4, + < 0.50f => 3, + < 0.75f => 2, + _ => 1, + }; + + if (targetPhase > this._nightmarePhase) + { + this._nightmarePhase = targetPhase; + await this.ExecuteNightmareTeleportAsync(nm, targetPhase, ct) + .ConfigureAwait(false); + } + } + } + } + + /// + /// Teleports Nightmare to a new phase position and restores his HP to full. + /// Uses a guard so the HP monitor cannot + /// re-trigger while the teleport sequence is in progress. + /// + private async Task ExecuteNightmareTeleportAsync(Monster nightmare, int phase, CancellationToken ct) + { + // Nightmare may have died between the HP check and this call. + if (!nightmare.IsAlive) + { + return; + } + + this._nightmareTeleporting = true; + try + { + var newPos = phase switch + { + 2 => NightmarePhase2Pos, + 3 => NightmarePhase3Pos, + 4 => NightmarePhase4Pos, + _ => NightmarePhase2Pos, + }; + + var msgKey = phase switch + { + 2 => nameof(PlayerMessage.KanturuNightmareTeleport2), + 3 => nameof(PlayerMessage.KanturuNightmareTeleport3), + 4 => nameof(PlayerMessage.KanturuNightmareTeleport4), + _ => null, + }; + + // Restore HP FIRST — any damage during the brief animation window will not kill Nightmare. + // This prevents the race condition where a simultaneous player hit (e.g. Cyclone) + // drops Nightmare's HP to 0 before the HP restore line, causing an incorrect death event. + nightmare.Health = (int)nightmare.Attributes[Stats.MaximumHealth]; + + // Short pause so clients can process the HP restore notification before the teleport. + await Task.Delay(TimeSpan.FromMilliseconds(500), CancellationToken.None).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + + // Teleport Nightmare to the phase-specific position. + await nightmare.MoveAsync(newPos).ConfigureAwait(false); + + // Restore HP a second time as a safety net — covers any hits landing in the 500 ms window. + nightmare.Health = (int)nightmare.Attributes[Stats.MaximumHealth]; + + if (msgKey is not null) + { + await this.ShowGoldenMessageAsync(msgKey).ConfigureAwait(false); + } + } + finally + { + this._nightmareTeleporting = false; + } + } + + /// + /// Opens the Elphis barrier by removing the NoGround terrain attribute from the + /// barrier area — both on the server walkmap and on every connected client. + /// The .att file stores value 8 (NoGround) for this zone, so the client shows it + /// as a visual void barrier. We must remove NoGround (not Blocked) to make it disappear. + /// + /// + /// This method is guarded by so it executes at most once + /// per game instance even when called concurrently from both + /// and the game loop. + /// + private async ValueTask OpenElphisBarrierAsync() + { + // Ensure we run only once — called both from OnMonsterDied (fire-and-forget) + // and from the game loop as a fallback. + if (Interlocked.CompareExchange(ref this._barrierOpened, 1, 0) != 0) + { + return; + } + + try + { + this.Logger.LogInformation("Kanturu: opening Elphis barrier."); + + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.KanturuBarrierOpening)).ConfigureAwait(false); + + // Monster count = 0 (Nightmare defeated). + await this.ShowMonsterUserCountAsync(0, this.PlayerCount).ConfigureAwait(false); + + // Victory camera-out cinematic (KANTURU_NIGHTMARE_DIRECTION_END = 4). + await this.ShowNightmareStateToAllAsync(KanturuNightmareDetailState.End).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + + // 1. Send SUCCESS result overlay (Success_kantru.tga). + // The client requires state=NightmareBattle to show it, which was set when + // Nightmare spawned. Sending this before the Tower state change ensures + // the overlay renders while the client is still in the Nightmare state. + await this.ForEachPlayerAsync(player => + player.InvokeViewPlugInAsync(p => + p.ShowBattleResultAsync(KanturuBattleResult.Victory)) + .AsTask()).ConfigureAwait(false); + + // 2. Send Tower state → client reloads EncTerrain(n)01.att (barrier-open terrain), + // switches to Tower music, and plays the success sound. + await this.ShowTowerStateToAllAsync(KanturuTowerDetailState.Revitalization).ConfigureAwait(false); + + // 3. Update server-side walkmap so the AI pathfinder and movement checks + // treat the formerly-blocked cells as passable. + var terrain = this.Map.Terrain; + foreach (var (startX, startY, endX, endY) in ElphisBarrierAreas) + { + for (var x = startX; x <= endX; x++) + { + for (var y = startY; y <= endY; y++) + { + terrain.WalkMap[x, y] = true; + terrain.UpdateAiGridValue(x, y); + } + } + } + + // 4. Also send the legacy 0x46 ChangeTerrainAttributes packet as a backup. + // If EncTerrain(n)01.att is missing on the client, this packet still clears + // the NoGround flag on the existing terrain so the visual barrier disappears. + var areas = (IReadOnlyCollection<(byte, byte, byte, byte)>)ElphisBarrierAreas; + await this.ForEachPlayerAsync(player => + player.InvokeViewPlugInAsync(p => + p.ChangeAttributesAsync(TerrainAttributeType.NoGround, setAttribute: false, areas)) + .AsTask()).ConfigureAwait(false); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "{context}: Unexpected error while opening Elphis barrier.", this); + } + } + + /// + /// Keeps the map open as the Tower of Refinement after Nightmare is defeated. + /// Sends a closing warning 5 minutes before the end of the configured duration. + /// + private async Task RunTowerOfRefinementAsync(CancellationToken ct) + { + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.KanturuTowerConquered)).ConfigureAwait(false); + + var duration = this._towerOfRefinementDuration; + var warningOffset = TimeSpan.FromMinutes(5); + + if (duration > warningOffset) + { + // Wait for most of the duration — use None so the delay isn't cancelled + // if all current players leave while new ones might still arrive. + await Task.Delay(duration - warningOffset, CancellationToken.None).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.KanturuTowerClosingWarning)).ConfigureAwait(false); + + await Task.Delay(warningOffset, CancellationToken.None).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + } + else + { + await Task.Delay(duration, CancellationToken.None).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + } + + // Tower closing notification + await this.ShowTowerStateToAllAsync(KanturuTowerDetailState.Notify).ConfigureAwait(false); + + await this.ShowGoldenMessageAsync(nameof(PlayerMessage.KanturuTowerClosed)).ConfigureAwait(false); + + await this.ShowTowerStateToAllAsync(KanturuTowerDetailState.Close).ConfigureAwait(false); + } + + private async Task TeleportToNightmareRoomAsync(CancellationToken ct) + { + var nightmareEntry = new Point(79, 98); + + // Step 1: Send 0xD1/0x03 with state=MayaBattle, detailState=EndCycleMaya3 (16). + // This matches KANTURU_MAYA_DIRECTION_ENDCYCLE_MAYA3 on the client, which triggers + // the full Maya→Nightmare transition cinematic via CKanturuDirection::Move2ndDirection(): + // Stage 0 — camera flies to the Maya room (196, 85). + // Stage 1 — m_bMayaDie=true: Maya body plays its explosion animation; waits for it to finish. + // Stage 2 — m_bDownHero=true: the hero "falls through the floor" into the Nightmare zone. + // NOTE: 0xD1/0x04 result=1 (Success_kantru.tga) must NOT be sent here — that overlay + // is only correct after Nightmare is defeated and is already sent in OpenElphisBarrierAsync. + await this.ShowMayaBattleStateToAllAsync(KanturuMayaDetailState.EndCycleMaya3).ConfigureAwait(false); + + // Step 2: Wait for the client cinematic to complete before teleporting. + // The three stages take roughly: camera pan ~3 s + explosion ~4 s + fall ~3 s ≈ 10 s. + // Use CancellationToken.None so the animation is never skipped mid-sequence. + await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken.None).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + + // Step 3: Teleport all players to the Nightmare zone entry point. + // Same map (39), so MoveAsync is correct (sends MoveType.Instant / packet 0x15). + // Mirrors C++ MoveAllUser(gate 134) → destination (79, 98). + await this.ForEachPlayerAsync(player => + player.MoveAsync(nightmareEntry).AsTask()).ConfigureAwait(false); + + // Step 4: Play the warp arrival animation at the Nightmare zone entry point. + // MapChangeFailedAsync (0xC2/0x1C) must be sent AFTER MoveAsync so the warp bubble + // renders at (79, 98) and not at the Maya area. A brief pause ensures the client + // has processed the position update before the animation triggers. + // The fall cinematic (EndCycleMaya3 / m_bDownHero) is complete at this point + // — 10 s have elapsed — so this packet does NOT interfere with it. + // The bubble also briefly locks player input, preventing movement/attacks + // during the visual scene transition. + await Task.Delay(TimeSpan.FromMilliseconds(200), CancellationToken.None).ConfigureAwait(false); + await this.ForEachPlayerAsync(player => + player.InvokeViewPlugInAsync(p => + p.MapChangeFailedAsync()).AsTask()).ConfigureAwait(false); + } + + /// + /// Periodically broadcasts the Maya wide-area attack visual (0xD1/0x06) during the Maya battle. + /// Alternates between storm (type 0) and stone-rain (type 1) every 15 seconds. + /// This triggers the MayaAction animation on the Maya body object in the client, + /// which is a purely cosmetic effect — actual damage is handled by the monsters' AttackSkill. + /// + private async Task RunMayaWideAreaAttacksAsync(CancellationToken ct) + { + var attackType = KanturuMayaAttackType.Storm; + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(15), ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + // Skip the broadcast during inter-phase standby — Maya stays idle, no visual attacks. + if (!this._mayaAttacksPaused) + { + try + { + await this.ForEachPlayerAsync(player => + player.InvokeViewPlugInAsync(p => + p.ShowMayaWideAreaAttackAsync(attackType)).AsTask()).ConfigureAwait(false); + } + catch (Exception ex) + { + this.Logger.LogWarning(ex, "{context}: Error sending Maya wide-area attack broadcast.", this); + } + } + + // Alternate Storm → Rain → Storm → … + attackType = attackType == KanturuMayaAttackType.Storm + ? KanturuMayaAttackType.Rain + : KanturuMayaAttackType.Storm; + } + } + + /// + /// Periodically broadcasts Nightmare's special explosion attack to all map players. + /// Every 20 seconds this sends an Inferno skill animation (skill #14) from Nightmare, + /// which the client maps to the ATTACK4 animation on MODEL_DARK_SKULL_SOLDIER_5: + /// an Inferno explosion + 2×MODEL_CIRCLE visual effects — the iconic "explosion" attack + /// seen in official MU Online Season 6 Kanturu Nightmare battles. + /// + /// + /// This is a pure visual broadcast. Nightmare's actual AoE damage (Decay poison) is + /// already handled server-side by (#38). + /// + private async Task RunNightmareSpecialAttacksAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(20), ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + if (this._nightmareMonster is not { IsAlive: true } nm) + { + break; + } + + // Skip during a teleport sequence to avoid animation conflicts. + if (this._nightmareTeleporting) + { + continue; + } + + // Broadcast Inferno skill animation (#14) from Nightmare to all players on the map. + // Triggers ATTACK4 (frame 9): Inferno explosion + 2×MODEL_CIRCLE visual at Nightmare's position. + await this.ForEachPlayerAsync(player => + player.InvokeViewPlugInAsync(p => + p.ShowSkillAnimationAsync(nm, null, NightmareInfernoskillNumber, true)).AsTask()) + .ConfigureAwait(false); + } + } + + private async Task SendStandbyMessageAsync(string messageKey, CancellationToken ct) + { + // Pause Maya wide-area attacks for the full duration of the standby so the + // body stays visually idle (no storm/stone-rain effects between phases). + this._mayaAttacksPaused = true; + try + { + // Hide the in-map HUD (INTERFACE_KANTURU_INFO) during the inter-phase standby. + // Sending detailState=None(0) while in MayaBattle state makes the client hide + // the monster count / user count / timer panel until the next phase begins. + // Maya body (#364) stays at its spawn position — it is ~27 tiles from the fight + // room and well outside its ViewRange=9, so it naturally idles without attacking. + await this.ShowMayaBattleStateToAllAsync(KanturuMayaDetailState.None).ConfigureAwait(false); + + await this.ShowGoldenMessageAsync(messageKey).ConfigureAwait(false); + + await Task.Delay(TimeSpan.FromMinutes(2), ct).ConfigureAwait(false); + } + finally + { + this._mayaAttacksPaused = false; + } + } + + private async Task AdvancePhaseAsync(KanturuPhase phase, byte waveNumber, int killTarget, string messageKey, CancellationToken ct) + { + Interlocked.Exchange(ref this._waveKillCount, 0); + this._waveKillTarget = killTarget; + this._phaseComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this._phase = phase; + + await this._mapInitializer.InitializeNpcsOnWaveStartAsync(this.Map, this, waveNumber).ConfigureAwait(false); + + // Broadcast the initial monster count so the HUD shows the correct number from the start. + await this.ShowMonsterUserCountAsync(killTarget, this.PlayerCount).ConfigureAwait(false); + + await this.ShowGoldenMessageAsync(messageKey).ConfigureAwait(false); + + await this._phaseComplete.Task.WaitAsync(ct).ConfigureAwait(false); + } + + /// + /// Broadcasts the Maya battle detail state (0xD1/0x03) to all players. + /// Updates and + /// so the Gateway NPC can report the current phase in the 0xD1/0x00 dialog. + /// + private ValueTask ShowMayaBattleStateToAllAsync(KanturuMayaDetailState detailState) + { + this.CurrentKanturuState = KanturuState.MayaBattle; + this.CurrentKanturuDetailState = ConvertMayaDetail(detailState); + return this.ForEachPlayerAsync(player => + player.InvokeViewPlugInAsync(p => + p.ShowMayaBattleStateAsync(detailState)).AsTask()); + } + + /// + /// Broadcasts the Nightmare battle detail state (0xD1/0x03) to all players. + /// Updates and . + /// + private ValueTask ShowNightmareStateToAllAsync(KanturuNightmareDetailState detailState) + { + this.CurrentKanturuState = KanturuState.NightmareBattle; + this.CurrentKanturuDetailState = ConvertNightmareDetail(detailState); + return this.ForEachPlayerAsync(player => + player.InvokeViewPlugInAsync(p => + p.ShowNightmareStateAsync(detailState)).AsTask()); + } + + /// + /// Broadcasts the Tower detail state (0xD1/0x03) to all players. + /// Updates and . + /// + private ValueTask ShowTowerStateToAllAsync(KanturuTowerDetailState detailState) + { + this.CurrentKanturuState = KanturuState.Tower; + this.CurrentKanturuDetailState = ConvertTowerDetail(detailState); + return this.ForEachPlayerAsync(player => + player.InvokeViewPlugInAsync(p => + p.ShowTowerStateAsync(detailState)).AsTask()); + } + + /// + /// Broadcasts packet 0xD1/0x07 (Kanturu monster/user count) to all players currently on the map. + /// The view plugin clamps the values to byte range before sending. + /// This method does not throw — callers in rely on this guarantee. + /// + private ValueTask ShowMonsterUserCountAsync(int monsterCount, int userCount) + { + return this.ForEachPlayerAsync(player => + player.InvokeViewPlugInAsync(p => + p.ShowMonsterUserCountAsync(monsterCount, userCount)).AsTask()); + } + + /// + /// Broadcasts packet 0xD1/0x05 (Kanturu time limit) to all players currently on the map. + /// + private ValueTask ShowTimeLimitToAllAsync(TimeSpan timeLimit) + { + return this.ForEachPlayerAsync(player => + player.InvokeViewPlugInAsync(p => + p.ShowTimeLimitAsync(timeLimit)).AsTask()); + } + + private static byte ConvertMayaDetail(KanturuMayaDetailState detail) => detail switch + { + KanturuMayaDetailState.None => 0, + KanturuMayaDetailState.Notify => 2, + KanturuMayaDetailState.Monster1 => 3, + KanturuMayaDetailState.MayaLeft => 4, + KanturuMayaDetailState.Monster2 => 8, + KanturuMayaDetailState.MayaRight => 9, + KanturuMayaDetailState.Monster3 => 13, + KanturuMayaDetailState.BothHands => 14, + KanturuMayaDetailState.EndCycleMaya3 => 16, + _ => throw new ArgumentOutOfRangeException(nameof(detail), detail, null), + }; + + private static byte ConvertNightmareDetail(KanturuNightmareDetailState detail) => detail switch + { + KanturuNightmareDetailState.None => 0, + KanturuNightmareDetailState.Idle => 1, + KanturuNightmareDetailState.Intro => 2, + KanturuNightmareDetailState.Battle => 3, + KanturuNightmareDetailState.End => 4, + _ => throw new ArgumentOutOfRangeException(nameof(detail), detail, null), + }; + + private static byte ConvertTowerDetail(KanturuTowerDetailState detail) => detail switch + { + KanturuTowerDetailState.None => 0, + KanturuTowerDetailState.Revitalization => 1, + KanturuTowerDetailState.Notify => 2, + KanturuTowerDetailState.Close => 3, + _ => throw new ArgumentOutOfRangeException(nameof(detail), detail, null), + }; +} diff --git a/src/GameLogic/PlugIns/KanturuGatewayPlugIn.cs b/src/GameLogic/PlugIns/KanturuGatewayPlugIn.cs new file mode 100644 index 000000000..dd1f2f826 --- /dev/null +++ b/src/GameLogic/PlugIns/KanturuGatewayPlugIn.cs @@ -0,0 +1,122 @@ +// +// 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.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames; +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.PlugIns; + +/// +/// Handles the Gateway Machine NPC (NPC #367) in Kanturu Relics. +/// When a player talks to this NPC, the server sends a 0xD1/0x00 StateInfo +/// packet so that the client opens the INTERFACE_KANTURU2ND_ENTERNPC dialog. +/// The actual entry is triggered later when the client sends 0xD1/0x01 +/// (KanturuEnterRequest) — handled by . +/// +[Guid("B7E4D2A3-1F85-4DAB-9074-19B4708389D5")] +[PlugIn] +[Display(Name = nameof(KanturuGatewayPlugIn), Description = "Handles the Kanturu Gateway Machine NPC for event entry.")] +public class KanturuGatewayPlugIn : IPlayerTalkToNpcPlugIn +{ + private const short GatewayMachineNumber = 367; + + // Detail state for the dialog when entry is open: + // KANTURU_MAYA_DIRECTION_STANBY1 = 1 — shows user count and enables Enter button. + private const byte DetailStandbyOpen = 1; + + /// + public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs) + { + if (npc.Definition.Number != GatewayMachineNumber) + { + return; + } + + // Mark as handled before any await so TalkNpcAction sees it synchronously. + eventArgs.HasBeenHandled = true; + + await SendKanturuStateInfoAsync(player).ConfigureAwait(false); + } + + /// + /// Sends the 0xD1/0x00 StateInfo packet to the player so the client opens the + /// gateway dialog. Also called from + /// when the client refreshes the dialog. + /// + public static async ValueTask SendKanturuStateInfoAsync(Player player) + { + var miniGameStartPlugIn = player.GameContext.PlugInManager + .GetStrategy(MiniGameType.Kanturu); + + var miniGameDefinition = player.GetSuitableMiniGameDefinition(MiniGameType.Kanturu, 1); + + if (miniGameStartPlugIn is null || miniGameDefinition is null) + { + // Event not configured — show nothing; the client won't open the dialog. + return; + } + + // Always fetch the running context first so we can reflect the live event state + // regardless of whether the initial entry window is open or has already closed. + var ctx = await miniGameStartPlugIn + .GetMiniGameContextAsync(player.GameContext, miniGameDefinition) + .ConfigureAwait(false); + + var timeUntilOpening = await miniGameStartPlugIn + .GetDurationUntilNextStartAsync(player.GameContext, miniGameDefinition) + .ConfigureAwait(false); + + KanturuState state; + byte detailState; + bool canEnter; + int userCount; + TimeSpan remainingTime; + + if (ctx is KanturuContext kanturuCtx) + { + // A Kanturu event is actively running — reflect its real-time phase. + // The client dialog will show "MayaBattle" or "NightmareBattle" as appropriate, + // and optionally the Maya sub-phase (e.g., Monster1 or Maya2) when available. + state = kanturuCtx.CurrentKanturuState; + detailState = kanturuCtx.CurrentKanturuDetailState; + + // Entry allowed only during Maya-battle phases (including inter-phase standby). + // NightmareBattle and Tower phase are both sealed — players who are already + // inside the event access the Refinery Tower by walking through the opened + // Elphis barrier; the Gateway does not re-admit anyone for those phases. + canEnter = state == KanturuState.MayaBattle; + + userCount = ctx.PlayerCount; + remainingTime = TimeSpan.Zero; + } + else if (timeUntilOpening == TimeSpan.Zero) + { + // Entry window is open but the game context has not been created yet + // (race: the scheduler opened the window but OnGameStartAsync hasn't run). + state = KanturuState.MayaBattle; + detailState = DetailStandbyOpen; + canEnter = true; + userCount = 0; + remainingTime = TimeSpan.Zero; + } + else + { + // No active event — show countdown to the next scheduled start. + state = KanturuState.Standby; + detailState = 1; // STANBY_START — client shows "Opens in X minutes" + canEnter = false; + userCount = 0; + remainingTime = timeUntilOpening ?? TimeSpan.Zero; + } + + await player.InvokeViewPlugInAsync(p => + p.ShowStateInfoAsync(state, detailState, canEnter, userCount, remainingTime)) + .ConfigureAwait(false); + } +} diff --git a/src/GameLogic/PlugIns/PeriodicTasks/KanturuGameServerState.cs b/src/GameLogic/PlugIns/PeriodicTasks/KanturuGameServerState.cs new file mode 100644 index 000000000..843f3ebde --- /dev/null +++ b/src/GameLogic/PlugIns/PeriodicTasks/KanturuGameServerState.cs @@ -0,0 +1,23 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; + +/// +/// The state of a game server state for the Kanturu event. +/// +public class KanturuGameServerState : PeriodicTaskGameServerState +{ + /// + /// Initializes a new instance of the class. + /// + /// The context. + public KanturuGameServerState(IGameContext context) + : base(context) + { + } + + /// + public override string Description => "Kanturu Event"; +} diff --git a/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartConfiguration.cs b/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartConfiguration.cs new file mode 100644 index 000000000..3d62e5f11 --- /dev/null +++ b/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartConfiguration.cs @@ -0,0 +1,34 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; + +/// +/// The Kanturu event start configuration. +/// +public class KanturuStartConfiguration : MiniGameStartConfiguration +{ + /// + /// Gets or sets how long the Tower of Refinement stays open after Nightmare is defeated. + /// Default is 1 hour. Set to zero to skip the tower phase. + /// + public TimeSpan TowerOfRefinementDuration { get; set; } = TimeSpan.FromHours(1); + + /// + /// Gets the default configuration for the Kanturu event. + /// The event runs once per day. After Nightmare is defeated the Tower of Refinement + /// stays open for 1 hour, then the event ends and the next occurrence is the following day. + /// The preparation window (entry phase) opens 3 minutes before the scheduled start time. + /// + public static KanturuStartConfiguration Default => + new() + { + PreStartMessageDelay = TimeSpan.Zero, + EntranceOpenedMessage = "Kanturu Refinery Tower entrance is open and closes in {0} minute(s).", + EntranceClosedMessage = "Kanturu Refinery Tower entrance closed.", + TaskDuration = TimeSpan.FromMinutes(135), + Timetable = [new TimeOnly(20, 0)], // 20:00 UTC — one occurrence per day + TowerOfRefinementDuration = TimeSpan.FromHours(1), + }; +} diff --git a/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartPlugIn.cs new file mode 100644 index 000000000..db85efcce --- /dev/null +++ b/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartPlugIn.cs @@ -0,0 +1,33 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.PlugIns; + +/// +/// This plugin enables the periodic start of the Kanturu Refinery Tower event. +/// +[PlugIn] +[Display(Name = nameof(KanturuStartPlugIn), Description = "Kanturu Refinery Tower event")] +[Guid("A8F3C2D1-9E74-4ECB-8963-08A3697278C4")] +public sealed class KanturuStartPlugIn : MiniGameStartBasePlugIn +{ + /// + public override MiniGameType Key => MiniGameType.Kanturu; + + /// + public override object CreateDefaultConfig() + { + return KanturuStartConfiguration.Default; + } + + /// + protected override KanturuGameServerState CreateState(IGameContext gameContext) + { + return new KanturuGameServerState(gameContext); + } +} diff --git a/src/GameLogic/Properties/PlayerMessage.Designer.cs b/src/GameLogic/Properties/PlayerMessage.Designer.cs index 7a70883a9..49ccaac4d 100644 --- a/src/GameLogic/Properties/PlayerMessage.Designer.cs +++ b/src/GameLogic/Properties/PlayerMessage.Designer.cs @@ -177,6 +177,195 @@ public static string BloodCastleCrystalStatusDestroyed { } } + /// + /// Looks up a localized string similar to The barrier opens — the tower awaits!. + /// + public static string KanturuBarrierOpening { + get { + return ResourceManager.GetString("KanturuBarrierOpening", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Both hands of Maya have appeared! Defeat them both!. + /// + public static string KanturuBothMayaHandsAppeared { + get { + return ResourceManager.GetString("KanturuBothMayaHandsAppeared", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Maya (Left Hand) has appeared! Destroy her!. + /// + public static string KanturuMayaLeftHandAppeared { + get { + return ResourceManager.GetString("KanturuMayaLeftHandAppeared", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Maya's hands have fallen! Collect your loot — the Nightmare awakens in 10 seconds.... + /// + public static string KanturuMayaHandsFallen { + get { + return ResourceManager.GetString("KanturuMayaHandsFallen", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Maya rises from the depths of the Refinery Tower!. + /// + public static string KanturuMayaRises { + get { + return ResourceManager.GetString("KanturuMayaRises", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Maya (Right Hand) has appeared! Destroy her!. + /// + public static string KanturuMayaRightHandAppeared { + get { + return ResourceManager.GetString("KanturuMayaRightHandAppeared", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to NIGHTMARE has appeared! Defeat him to claim victory!. + /// + public static string KanturuNightmareAppeared { + get { + return ResourceManager.GetString("KanturuNightmareAppeared", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Nightmare's guardians have appeared! NIGHTMARE awakens in 3 seconds!. + /// + public static string KanturuNightmareGuardiansAppeared { + get { + return ResourceManager.GetString("KanturuNightmareGuardiansAppeared", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Nightmare has teleported! He recovers his full strength!. + /// + public static string KanturuNightmareTeleport2 { + get { + return ResourceManager.GetString("KanturuNightmareTeleport2", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Nightmare teleports again! He is more powerful than ever!. + /// + public static string KanturuNightmareTeleport3 { + get { + return ResourceManager.GetString("KanturuNightmareTeleport3", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Nightmare is at his last stand! Finish him!. + /// + public static string KanturuNightmareTeleport4 { + get { + return ResourceManager.GetString("KanturuNightmareTeleport4", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Phase 1 cleared! Phase 2 begins in 2 minutes.... + /// + public static string KanturuPhase1Cleared { + get { + return ResourceManager.GetString("KanturuPhase1Cleared", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Phase 1: Defeat the monsters to unseal Maya's power!. + /// + public static string KanturuPhase1Start { + get { + return ResourceManager.GetString("KanturuPhase1Start", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Phase 2 cleared! Phase 3 begins in 2 minutes.... + /// + public static string KanturuPhase2Cleared { + get { + return ResourceManager.GetString("KanturuPhase2Cleared", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Phase 2: More of Maya's minions have arrived!. + /// + public static string KanturuPhase2Start { + get { + return ResourceManager.GetString("KanturuPhase2Start", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Phase 3: The final wave approaches!. + /// + public static string KanturuPhase3Start { + get { + return ResourceManager.GetString("KanturuPhase3Start", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Kanturu Tower has closed.. + /// + public static string KanturuTowerClosed { + get { + return ResourceManager.GetString("KanturuTowerClosed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Kanturu Event has ended. Better luck next time!. + /// + public static string KanturuDefeat { + get { + return ResourceManager.GetString("KanturuDefeat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Kanturu Tower closes in 5 minutes!. + /// + public static string KanturuTowerClosingWarning { + get { + return ResourceManager.GetString("KanturuTowerClosingWarning", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Kanturu Refinery Tower is conquered! The tower is now open.. + /// + public static string KanturuTowerConquered { + get { + return ResourceManager.GetString("KanturuTowerConquered", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Nightmare has been defeated! The Kanturu Refinery Tower is yours!. + /// + public static string KanturuVictory { + get { + return ResourceManager.GetString("KanturuVictory", resourceCulture); + } + } + /// /// Looks up a localized string similar to Can't delete a guild member. Remove the character from guild first.. /// diff --git a/src/GameLogic/Properties/PlayerMessage.resx b/src/GameLogic/Properties/PlayerMessage.resx index a85d7ce09..bc1161891 100644 --- a/src/GameLogic/Properties/PlayerMessage.resx +++ b/src/GameLogic/Properties/PlayerMessage.resx @@ -405,6 +405,69 @@ [{0}] Account of {1} has been unbanned. + + Maya rises from the depths of the Refinery Tower! + + + Phase 1: Defeat the monsters to unseal Maya's power! + + + Maya (Left Hand) has appeared! Destroy her! + + + Phase 1 cleared! Phase 2 begins in 2 minutes... + + + Phase 2: More of Maya's minions have arrived! + + + Maya (Right Hand) has appeared! Destroy her! + + + Phase 2 cleared! Phase 3 begins in 2 minutes... + + + Phase 3: The final wave approaches! + + + Both hands of Maya have appeared! Defeat them both! + + + Maya's hands have fallen! Collect your loot — the Nightmare awakens in 10 seconds... + + + Nightmare's guardians have appeared! NIGHTMARE awakens in 3 seconds! + + + NIGHTMARE has appeared! Defeat him to claim victory! + + + Nightmare has teleported! He recovers his full strength! + + + Nightmare teleports again! He is more powerful than ever! + + + Nightmare is at his last stand! Finish him! + + + Nightmare has been defeated! The Kanturu Refinery Tower is yours! + + + The barrier opens — the tower awaits! + + + The Kanturu Refinery Tower is conquered! The tower is now open. + + + The Kanturu Tower closes in 5 minutes! + + + The Kanturu Tower has closed. + + + The Kanturu Event has ended. Better luck next time! + {0} has acquired the {1} diff --git a/src/GameServer/MessageHandler/MiniGames/KanturuEnterRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/MiniGames/KanturuEnterRequestHandlerPlugIn.cs new file mode 100644 index 000000000..1afd8f684 --- /dev/null +++ b/src/GameServer/MessageHandler/MiniGames/KanturuEnterRequestHandlerPlugIn.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.MiniGames; +using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames; +using MUnique.OpenMU.Network.Packets.ClientToServer; +using MUnique.OpenMU.PlugIns; + +/// +/// Handler for 0xD1/0x01 — KanturuEnterRequest. +/// The client sends this at animation frame 42 of the Gateway Machine NPC animation, +/// after the player clicked "Enter" in the INTERFACE_KANTURU2ND_ENTERNPC dialog. +/// On success the player is teleported to the event map (map 39) — no result packet needed. +/// On failure already shows an error to the player. +/// +[PlugIn] +[Display(Name = "Kanturu Enter Request Handler", Description = "Handles 0xD1/0x01 (KanturuEnterRequest) and teleports the player into the event.")] +[Guid("E6A3C9B2-5D84-4F10-9B42-8C7A0F3D5E19")] +[BelongsToGroup(KanturuGroupHandlerPlugIn.GroupKey)] +internal class KanturuEnterRequestHandlerPlugIn : ISubPacketHandlerPlugIn +{ + private readonly EnterMiniGameAction _enterAction = new(); + + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => KanturuEnterRequest.SubCode; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + if (packet.Length < KanturuEnterRequest.Length + || player.SelectedCharacter?.CharacterClass is null) + { + return; + } + + // Try to enter the Kanturu mini game. + // On success: player is teleported to map 39 — the map change clears the dialog. + // On failure: TryEnterMiniGameAsync shows a message to the player and the client + // NPC animation resets naturally at frame 50. + await this._enterAction.TryEnterMiniGameAsync(player, MiniGameType.Kanturu, 1, 0xFF) + .ConfigureAwait(false); + } +} diff --git a/src/GameServer/MessageHandler/MiniGames/KanturuGroupHandlerPlugIn.cs b/src/GameServer/MessageHandler/MiniGames/KanturuGroupHandlerPlugIn.cs new file mode 100644 index 000000000..900b7b391 --- /dev/null +++ b/src/GameServer/MessageHandler/MiniGames/KanturuGroupHandlerPlugIn.cs @@ -0,0 +1,41 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.MessageHandler.MiniGames; + +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Group packet handler for the 0xD1 (Kanturu) packet group. +/// Routes sub-codes to the appropriate sub-handlers: +/// 0x00 — KanturuInfoRequest (client requests event state info → opens dialog). +/// 0x01 — KanturuEnterRequest (client requests to enter the event map). +/// +[PlugIn] +[Display(Name = "Kanturu Group Handler", Description = "Routes 0xD1 Kanturu client packets to sub-handlers.")] +[Guid("C4E8A1F2-7B93-4D06-9E45-2A1F8B3C7D02")] +internal class KanturuGroupHandlerPlugIn : GroupPacketHandlerPlugIn +{ + /// + /// The group key for the Kanturu packet group. + /// + internal const byte GroupKey = (byte)PacketType.KanturuGroup; + + /// + /// Initializes a new instance of the class. + /// + public KanturuGroupHandlerPlugIn(IClientVersionProvider clientVersionProvider, PlugInManager manager, ILoggerFactory loggerFactory) + : base(clientVersionProvider, manager, loggerFactory) + { + } + + /// + public override bool IsEncryptionExpected => false; + + /// + public override byte Key => GroupKey; +} diff --git a/src/GameServer/MessageHandler/MiniGames/KanturuInfoRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/MiniGames/KanturuInfoRequestHandlerPlugIn.cs new file mode 100644 index 000000000..4ae897367 --- /dev/null +++ b/src/GameServer/MessageHandler/MiniGames/KanturuInfoRequestHandlerPlugIn.cs @@ -0,0 +1,42 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.MessageHandler.MiniGames; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.Network.Packets.ClientToServer; +using MUnique.OpenMU.PlugIns; + +/// +/// Handler for 0xD1/0x00 — KanturuInfoRequest. +/// The client sends this packet periodically while the gateway dialog is open +/// (every ~5 seconds via the Refresh button or the auto-refresh timer). +/// The server responds with a fresh 0xD1/0x00 StateInfo packet so the dialog +/// shows current player count and remaining time. +/// +[PlugIn] +[Display(Name = "Kanturu Info Request Handler", Description = "Responds to 0xD1/0x00 (KanturuInfoRequest) with fresh event state info.")] +[Guid("D5F2B8A1-4C73-4E09-8A31-7B6F9E2C4D08")] +[BelongsToGroup(KanturuGroupHandlerPlugIn.GroupKey)] +internal class KanturuInfoRequestHandlerPlugIn : ISubPacketHandlerPlugIn +{ + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => KanturuInfoRequest.SubCode; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + if (packet.Length < KanturuInfoRequest.Length) + { + return; + } + + await KanturuGatewayPlugIn.SendKanturuStateInfoAsync(player).ConfigureAwait(false); + } +} diff --git a/src/GameServer/RemoteView/MiniGames/KanturuEventViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/KanturuEventViewPlugIn.cs new file mode 100644 index 000000000..565adc835 --- /dev/null +++ b/src/GameServer/RemoteView/MiniGames/KanturuEventViewPlugIn.cs @@ -0,0 +1,207 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.RemoteView.MiniGames; + +using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.PlugIns; + +/// +/// Sends Kanturu-specific state, result, and HUD packets (0xD1 group) to the client. +/// Packets are defined in ServerToClientPackets.xml and this plugin uses the +/// auto-generated extension methods from ConnectionExtensions. +/// +[PlugIn] +[Display(Name = "Kanturu Event View Plugin", Description = "Sends Kanturu event packets (0xD1 group) to the client.")] +[Guid("A3F1C8D2-5E94-4B7A-8C31-D6F2E0A49B15")] +public sealed class KanturuEventViewPlugIn : IKanturuEventViewPlugIn +{ + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The remote player this plugin sends packets to. + public KanturuEventViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask ShowStateInfoAsync(KanturuState state, byte detailState, bool canEnter, int userCount, TimeSpan remainingTime) + { + if (this._player.Connection is not { } connection) + { + return; + } + + await connection.SendKanturuStateInfoAsync( + ConvertState(state), + detailState, + canEnter, + (byte)Math.Min(userCount, byte.MaxValue), + (int)remainingTime.TotalSeconds).ConfigureAwait(false); + } + + /// + public async ValueTask ShowEnterResultAsync(KanturuEnterResult result) + { + if (this._player.Connection is not { } connection) + { + return; + } + + await connection.SendKanturuEnterResultAsync(ConvertEnterResult(result)).ConfigureAwait(false); + } + + /// + public async ValueTask ShowMayaBattleStateAsync(KanturuMayaDetailState detailState) + { + if (this._player.Connection is not { } connection) + { + return; + } + + await connection.SendKanturuStateChangeAsync( + KanturuStateChange.StateType.MayaBattle, + ConvertMayaDetail(detailState)).ConfigureAwait(false); + } + + /// + public async ValueTask ShowNightmareStateAsync(KanturuNightmareDetailState detailState) + { + if (this._player.Connection is not { } connection) + { + return; + } + + await connection.SendKanturuStateChangeAsync( + KanturuStateChange.StateType.NightmareBattle, + ConvertNightmareDetail(detailState)).ConfigureAwait(false); + } + + /// + public async ValueTask ShowTowerStateAsync(KanturuTowerDetailState detailState) + { + if (this._player.Connection is not { } connection) + { + return; + } + + await connection.SendKanturuStateChangeAsync( + KanturuStateChange.StateType.Tower, + ConvertTowerDetail(detailState)).ConfigureAwait(false); + } + + /// + public async ValueTask ShowBattleResultAsync(KanturuBattleResult result) + { + if (this._player.Connection is not { } connection) + { + return; + } + + await connection.SendKanturuBattleResultAsync(ConvertBattleResult(result)).ConfigureAwait(false); + } + + /// + public async ValueTask ShowTimeLimitAsync(TimeSpan timeLimit) + { + if (this._player.Connection is not { } connection) + { + return; + } + + await connection.SendKanturuTimeLimitAsync((int)timeLimit.TotalMilliseconds).ConfigureAwait(false); + } + + /// + public async ValueTask ShowMonsterUserCountAsync(int monsterCount, int userCount) + { + if (this._player.Connection is not { } connection) + { + return; + } + + await connection.SendKanturuMonsterUserCountAsync( + (byte)Math.Min(monsterCount, byte.MaxValue), + (byte)Math.Min(userCount, byte.MaxValue)).ConfigureAwait(false); + } + + /// + public async ValueTask ShowMayaWideAreaAttackAsync(KanturuMayaAttackType attackType) + { + if (this._player.Connection is not { } connection) + { + return; + } + + // ObjClassH and ObjClassL are ignored by the client (confirmed in WSclient.cpp: + // RecevieKanturu3rdMayaSKill reads only btType and passes it to MayaSceneMayaAction). + await connection.SendKanturuMayaWideAreaAttackAsync(0x00, 0x00, ConvertMayaAttackType(attackType)).ConfigureAwait(false); + } + + private static KanturuStateInfo.StateType ConvertState(KanturuState state) => state switch + { + KanturuState.None => KanturuStateInfo.StateType.None, + KanturuState.Standby => KanturuStateInfo.StateType.Standby, + KanturuState.MayaBattle => KanturuStateInfo.StateType.MayaBattle, + KanturuState.NightmareBattle => KanturuStateInfo.StateType.NightmareBattle, + KanturuState.Tower => KanturuStateInfo.StateType.Tower, + KanturuState.End => KanturuStateInfo.StateType.End, + _ => throw new ArgumentOutOfRangeException(nameof(state), state, null), + }; + + private static KanturuEnterResult.EnterResult ConvertEnterResult(KanturuEnterResult result) => result switch + { + KanturuEnterResult.Failed => KanturuEnterResult.EnterResult.Failed, + KanturuEnterResult.Success => KanturuEnterResult.EnterResult.Success, + _ => throw new ArgumentOutOfRangeException(nameof(result), result, null), + }; + + private static KanturuBattleResult.BattleResult ConvertBattleResult(KanturuBattleResult result) => result switch + { + KanturuBattleResult.Failure => KanturuBattleResult.BattleResult.Failure, + KanturuBattleResult.Victory => KanturuBattleResult.BattleResult.Victory, + _ => throw new ArgumentOutOfRangeException(nameof(result), result, null), + }; + + private static byte ConvertMayaDetail(KanturuMayaDetailState detail) => detail switch + { + KanturuMayaDetailState.None => 0, + KanturuMayaDetailState.Notify => 2, + KanturuMayaDetailState.Monster1 => 3, + KanturuMayaDetailState.MayaLeft => 4, + KanturuMayaDetailState.Monster2 => 8, + KanturuMayaDetailState.MayaRight => 9, + KanturuMayaDetailState.Monster3 => 13, + KanturuMayaDetailState.BothHands => 14, + KanturuMayaDetailState.EndCycleMaya3 => 16, + _ => throw new ArgumentOutOfRangeException(nameof(detail), detail, null), + }; + + private static byte ConvertNightmareDetail(KanturuNightmareDetailState detail) => detail switch + { + KanturuNightmareDetailState.None => 0, + KanturuNightmareDetailState.Idle => 1, + KanturuNightmareDetailState.Intro => 2, + KanturuNightmareDetailState.Battle => 3, + KanturuNightmareDetailState.End => 4, + _ => throw new ArgumentOutOfRangeException(nameof(detail), detail, null), + }; + + private static byte ConvertTowerDetail(KanturuTowerDetailState detail) => detail switch + { + KanturuTowerDetailState.None => 0, + KanturuTowerDetailState.Revitalization => 1, + KanturuTowerDetailState.Notify => 2, + KanturuTowerDetailState.Close => 3, + _ => throw new ArgumentOutOfRangeException(nameof(detail), detail, null), + }; + + private static KanturuMayaWideAreaAttack.AttackType ConvertMayaAttackType(KanturuMayaAttackType attackType) => attackType switch + { + KanturuMayaAttackType.Storm => KanturuMayaWideAreaAttack.AttackType.Storm, + KanturuMayaAttackType.Rain => KanturuMayaWideAreaAttack.AttackType.Rain, + _ => throw new ArgumentOutOfRangeException(nameof(attackType), attackType, null), + }; +} diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml index 65e8368e7..82cf064e6 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -11316,4 +11316,285 @@ + + C1HeaderWithSubCode + D1 + 00 + KanturuStateInfo + 12 + ServerToClient + The player requests state information from the Kanturu gateway NPC. + The client shows the Kanturu entry dialog (INTERFACE_KANTURU2ND_ENTERNPC) with event state, detail state, whether entry is possible, current player count and remaining time. + + + 4 + Enum + StateType + State + + + 5 + Byte + DetailState + Detail state; semantics depend on the main State field. See the game logic enums for per-state values. + + + 6 + Boolean + CanEnter + 1 = entrance is open (Enter button enabled); 0 = entrance closed. + + + 7 + Byte + UserCount + Number of players currently inside the event map (capped at 255). + + + 8 + IntegerLittleEndian + RemainSeconds + Remaining time in seconds. Standby: seconds until event opens. Tower: seconds the tower has been open. Otherwise 0. + + + + + StateType + Main state of the Kanturu event, matching the client KANTURU_STATE_TYPE enum. + + + None + No active state. + 0 + + + Standby + Waiting for players to enter before the event starts. + 1 + + + MayaBattle + Maya battle phase covering Phases 1 through 3 and their boss waves. + 2 + + + NightmareBattle + Nightmare battle phase after all three Maya phases are cleared. + 3 + + + Tower + Tower of Refinement phase; opens after Nightmare is defeated. + 4 + + + End + Event has ended. + 5 + + + + + + + C1HeaderWithSubCode + D1 + 01 + KanturuEnterResult + 5 + ServerToClient + The player attempted to enter the Kanturu event through the gateway NPC. + The client closes the NPC animation and shows an error popup on failure. On success the player has already been teleported to the event map. + + + 4 + Enum + EnterResult + Result + + + + + EnterResult + Result of the Kanturu enter request. + + + Failed + Entry failed (generic failure). + 0 + + + Success + The player has been successfully entered into the event. + 1 + + + + + + + C1HeaderWithSubCode + D1 + 03 + KanturuStateChange + 6 + ServerToClient + The Kanturu event transitions to a new phase or sub-phase. + The client shows or hides the in-map HUD, switches background music, and when entering the Tower state reloads the barrier-open terrain file (EncTerrain_n_01.att) to visually remove the Elphis barrier. + + + 4 + Enum + StateType + State + Refers to the KanturuStateInfo.StateType enum values. + + + 5 + Byte + DetailState + Detail state within the main state. Maya battle: 0=none, 2=notify, 3=monster1, 4=maya1, 8=monster2, 9=maya2, 13=monster3, 14=maya3, 16=endcycle. Nightmare: 0=none, 1=idle, 2=intro, 3=battle, 4=end. Tower: 0=none, 1=revitalization, 2=notify, 3=close. + + + + + StateType + Main state; see KanturuStateInfo.StateType for value descriptions. + + None0 + Standby1 + MayaBattle2 + NightmareBattle3 + Tower4 + End5 + + + + + + C1HeaderWithSubCode + D1 + 04 + KanturuBattleResult + 5 + ServerToClient + The Kanturu event ends with a victory or defeat outcome. + The client displays the Success_kantru.tga overlay on victory or the Failure_kantru.tga overlay on defeat. + + + 4 + Enum + BattleResult + Result + + + + + BattleResult + Outcome of the Kanturu battle. + + + Failure + The event ended in failure; shows Failure_kantru.tga. + 0 + + + Victory + Nightmare was defeated; shows Success_kantru.tga. + 1 + + + + + + + C1HeaderWithSubCode + D1 + 05 + KanturuTimeLimit + 8 + ServerToClient + A timed phase begins in the Kanturu event. + The client starts a countdown timer shown in the Kanturu HUD. The value is divided by 1000 to obtain seconds. + + + 4 + IntegerLittleEndian + TimeLimitMilliseconds + Countdown duration in milliseconds. + + + + + C1HeaderWithSubCode + D1 + 06 + KanturuMayaWideAreaAttack + 7 + ServerToClient + The Maya body executes a wide-area attack during the Maya battle phase. + The client calls MayaSceneMayaAction(type) which plays one of two visual sequences on the Maya body model: storm (0) or stone-rain (1). This is a purely cosmetic packet — damage is handled server-side. + + + 4 + Byte + ObjClassH + High byte of the Maya object class; ignored by the client. + + + 5 + Byte + ObjClassL + Low byte of the Maya object class; ignored by the client. + + + 6 + Enum + AttackType + Type + + + + + AttackType + Visual type of the Maya wide-area attack. + + + Storm + Stone-storm effect (MODEL_STORM3 plus falling debris around the hero). + 0 + + + Rain + Stone-rain effect (MODEL_MAYASTONE projectiles falling on the hero). + 1 + + + + + + + C1HeaderWithSubCode + D1 + 07 + KanturuMonsterUserCount + 6 + ServerToClient + A monster is killed or the player count changes during the Kanturu event. + The client updates the monster count and user count numbers displayed in the Kanturu HUD. + + + 4 + Byte + MonsterCount + Number of monsters still alive in the current wave (capped at 255). + + + 5 + Byte + UserCount + Number of players currently inside the event map (capped at 255). + + + \ No newline at end of file diff --git a/src/Persistence/Initialization/VersionSeasonSix/Events/KanturuInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/Events/KanturuInitializer.cs new file mode 100644 index 000000000..b49606b19 --- /dev/null +++ b/src/Persistence/Initialization/VersionSeasonSix/Events/KanturuInitializer.cs @@ -0,0 +1,49 @@ +// +// 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 Kanturu Refinery Tower event. +/// +internal class KanturuInitializer : InitializerBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The context. + /// The game configuration. + public KanturuInitializer(IContext context, GameConfiguration gameConfiguration) + : base(context, gameConfiguration) + { + } + + /// + public override void Initialize() + { + var kanturu = this.Context.CreateNew(); + kanturu.SetGuid((short)MiniGameType.Kanturu, 1); + this.GameConfiguration.MiniGameDefinitions.Add(kanturu); + kanturu.Name = "Kanturu Refinery Tower"; + kanturu.Description = "Event definition for the Kanturu Refinery Tower event."; + kanturu.EnterDuration = TimeSpan.FromMinutes(3); + kanturu.GameDuration = TimeSpan.FromMinutes(135); + kanturu.ExitDuration = TimeSpan.FromMinutes(1); + kanturu.MaximumPlayerCount = 10; + kanturu.MinimumCharacterLevel = 350; + kanturu.MaximumCharacterLevel = 400; + kanturu.MinimumSpecialCharacterLevel = 350; + kanturu.MaximumSpecialCharacterLevel = 400; + kanturu.Entrance = this.GameConfiguration.Maps.First(m => m.Number == KanturuEvent.Number).ExitGates.Single(g => g.IsSpawnGate); + kanturu.Type = MiniGameType.Kanturu; + kanturu.TicketItem = null; + kanturu.GameLevel = 1; + kanturu.MapCreationPolicy = MiniGameMapCreationPolicy.Shared; + kanturu.SaveRankingStatistics = false; + kanturu.AllowParty = true; + } +} diff --git a/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs index d00199a60..a6def80d3 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/GameConfigurationInitializer.cs @@ -83,6 +83,7 @@ public override void Initialize() new DevilSquareInitializer(this.Context, this.GameConfiguration).Initialize(); new BloodCastleInitializer(this.Context, this.GameConfiguration).Initialize(); new ChaosCastleInitializer(this.Context, this.GameConfiguration).Initialize(); + new KanturuInitializer(this.Context, this.GameConfiguration).Initialize(); } private void CreateJewelMixes() diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/KanturuEvent.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/KanturuEvent.cs index 7b3df570c..13e9a6a39 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Maps/KanturuEvent.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/KanturuEvent.cs @@ -1,11 +1,14 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps; +using MUnique.OpenMU.AttributeSystem; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.Persistence.Initialization.Skills; /// /// Initialization for the Kanturu event map. @@ -38,23 +41,28 @@ public KanturuEvent(IContext context, GameConfiguration gameConfiguration) /// protected override string MapName => Name; + /// + /// Players who die inside the Kanturu Event map respawn at Kanturu Relics (map 38). + /// + protected override byte SafezoneMapNumber => KanturuRelics.Number; + /// protected override void CreateMapAttributeRequirements() { - // It's only required during the event. Events are not implemented yet - probably we need multiple GameMapDefinitions, for each state of a map. this.CreateRequirement(Stats.MoonstonePendantEquipped, 1); } /// protected override IEnumerable CreateNpcSpawns() { - yield return this.CreateMonsterSpawn(1, this.GameConfiguration.Monsters.First(m => m.Number == 368), 77, 177, Direction.SouthWest); // Elpis NPC + yield return this.CreateMonsterSpawn(1, this.NpcDictionary[368], 77, 177, Direction.SouthWest); // Elpis NPC } /// protected override IEnumerable CreateMonsterSpawns() { - var laserTrap = this.GameConfiguration.Monsters.First(m => m.Number == 106); + // Laser traps (auto-spawn on map load) + var laserTrap = this.NpcDictionary[106]; yield return this.CreateMonsterSpawn(100, laserTrap, 60, 108); yield return this.CreateMonsterSpawn(101, laserTrap, 173, 61); yield return this.CreateMonsterSpawn(102, laserTrap, 173, 64); @@ -93,11 +101,190 @@ protected override IEnumerable CreateMonsterSpawns() yield return this.CreateMonsterSpawn(135, laserTrap, 178, 56); yield return this.CreateMonsterSpawn(136, laserTrap, 176, 58); yield return this.CreateMonsterSpawn(137, laserTrap, 174, 59); + + // --- Event wave spawns (OnceAtWaveStart) --- + // Boss positions: Maya Left (202,83), Maya Right (189,82), Nightmare (78,141) + // Maya room (bounded by laser traps): X:174-217, Y:54-83 + // Nightmare zone: X:75-88, Y:97-141 + + // Wave 0: Maya body rises when battle starts (fixed position below the fight room) + var maya = this.NpcDictionary[364]; + yield return this.CreateMonsterSpawn(299, maya, 188, 188, 110, 110, 1, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 0); + + // Wave 1: Phase 1 — 30 Blade Hunter + 10 Dreadfear (Maya room) + var bladeHunter = this.NpcDictionary[354]; + var dreadfear = this.NpcDictionary[360]; + yield return this.CreateMonsterSpawn(200, bladeHunter, 175, 215, 58, 86, 30, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 1); + yield return this.CreateMonsterSpawn(201, dreadfear, 175, 215, 58, 86, 10, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 1); + + // Wave 2: Phase 1 Boss — Maya Left Hand + var mayaLeft = this.NpcDictionary[362]; + yield return this.CreateMonsterSpawn(210, mayaLeft, 202, 202, 83, 83, 1, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 2); + + // Wave 3: Phase 2 — 30 Blade Hunter + 10 Dreadfear (Maya room) + yield return this.CreateMonsterSpawn(220, bladeHunter, 175, 215, 58, 86, 30, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 3); + yield return this.CreateMonsterSpawn(221, dreadfear, 175, 215, 58, 86, 10, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 3); + + // Wave 4: Phase 2 Boss — Maya Right Hand + var mayaRight = this.NpcDictionary[363]; + yield return this.CreateMonsterSpawn(230, mayaRight, 189, 189, 82, 82, 1, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 4); + + // Wave 5: Phase 3 — 10 Dreadfear + 10 Twin Tale (Maya room) + var twinTale = this.NpcDictionary[359]; + yield return this.CreateMonsterSpawn(240, dreadfear, 175, 215, 58, 86, 10, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 5); + yield return this.CreateMonsterSpawn(241, twinTale, 175, 215, 58, 86, 10, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 5); + + // Wave 6: Phase 3 Bosses — Maya Left Hand + Maya Right Hand + yield return this.CreateMonsterSpawn(250, mayaLeft, 202, 202, 83, 83, 1, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 6); + yield return this.CreateMonsterSpawn(251, mayaRight, 189, 189, 82, 82, 1, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 6); + + // Wave 7: Nightmare Prep — 15 Genocider + 15 Dreadfear + 15 Persona (Nightmare zone) + var genocider = this.NpcDictionary[357]; + var persona = this.NpcDictionary[358]; + yield return this.CreateMonsterSpawn(260, genocider, 75, 88, 97, 137, 15, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 7); + yield return this.CreateMonsterSpawn(261, dreadfear, 75, 88, 97, 137, 15, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 7); + yield return this.CreateMonsterSpawn(262, persona, 75, 88, 97, 137, 15, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 7); + + // Wave 8: Nightmare + var nightmare = this.NpcDictionary[361]; + yield return this.CreateMonsterSpawn(270, nightmare, 78, 78, 143, 143, 1, Direction.Undefined, SpawnTrigger.OnceAtWaveStart, 8); } /// protected override void CreateMonsters() { - // no monsters to create + // Maya (#364) - full body boss, rises at event start + { + var monster = this.Context.CreateNew(); + this.GameConfiguration.Monsters.Add(monster); + monster.Number = 364; + monster.Designation = "Maya"; + monster.MoveRange = 3; + monster.AttackRange = 6; + monster.ViewRange = 9; + monster.MoveDelay = new TimeSpan(400 * TimeSpan.TicksPerMillisecond); + monster.AttackDelay = new TimeSpan(2000 * TimeSpan.TicksPerMillisecond); + monster.RespawnDelay = new TimeSpan(0); + monster.Attribute = 2; + monster.NumberOfMaximumItemDrops = 7; + var attributes = new Dictionary + { + { Stats.Level, 140 }, + { Stats.MaximumHealth, 5_000_000 }, + { Stats.MinimumPhysBaseDmg, 2500 }, + { Stats.MaximumPhysBaseDmg, 3000 }, + { Stats.DefenseBase, 6500 }, + { Stats.AttackRatePvm, 2800 }, + { Stats.DefenseRatePvm, 2200 }, + { Stats.PoisonResistance, 50f / 255 }, + { Stats.IceResistance, 50f / 255 }, + { Stats.LightningResistance, 50f / 255 }, + { Stats.FireResistance, 50f / 255 }, + }; + monster.AddAttributes(attributes, this.Context, this.GameConfiguration); + monster.SetGuid(monster.Number); + } + + // Nightmare (#361) + { + var monster = this.Context.CreateNew(); + this.GameConfiguration.Monsters.Add(monster); + monster.Number = 361; + monster.Designation = "Nightmare"; + monster.MoveRange = 3; + monster.AttackRange = 5; + monster.ViewRange = 9; + monster.MoveDelay = new TimeSpan(400 * TimeSpan.TicksPerMillisecond); + monster.AttackDelay = new TimeSpan(1600 * TimeSpan.TicksPerMillisecond); + monster.RespawnDelay = new TimeSpan(0); + monster.Attribute = 2; + monster.NumberOfMaximumItemDrops = 5; + // Nightmare uses a Decay (poison) area attack — applies poison DoT to players on each hit. + monster.AttackSkill = this.GameConfiguration.Skills.FirstOrDefault(s => s.Number == (short)SkillNumber.Decay); + var attributes = new Dictionary + { + { Stats.Level, 145 }, + { Stats.MaximumHealth, 1_500_000 }, + { Stats.MinimumPhysBaseDmg, 3000 }, + { Stats.MaximumPhysBaseDmg, 3500 }, + { Stats.DefenseBase, 7500 }, + { Stats.AttackRatePvm, 3000 }, + { Stats.DefenseRatePvm, 2500 }, + { Stats.PoisonResistance, 50f / 255 }, + { Stats.IceResistance, 50f / 255 }, + { Stats.LightningResistance, 50f / 255 }, + { Stats.FireResistance, 50f / 255 }, + }; + monster.AddAttributes(attributes, this.Context, this.GameConfiguration); + monster.SetGuid(monster.Number); + } + + // Maya Left Hand (#362) + { + var monster = this.Context.CreateNew(); + this.GameConfiguration.Monsters.Add(monster); + monster.Number = 362; + monster.Designation = "Maya (Hand Left)"; + monster.MoveRange = 3; + monster.AttackRange = 5; + monster.ViewRange = 8; + monster.MoveDelay = new TimeSpan(400 * TimeSpan.TicksPerMillisecond); + monster.AttackDelay = new TimeSpan(1600 * TimeSpan.TicksPerMillisecond); + monster.RespawnDelay = new TimeSpan(0); + monster.Attribute = 2; + monster.NumberOfMaximumItemDrops = 3; + // Maya Left Hand uses IceStorm — AoE ice attack that hits a 3×3 tile area around the target. + monster.AttackSkill = this.GameConfiguration.Skills.FirstOrDefault(s => s.Number == (short)SkillNumber.IceStorm); + var attributes = new Dictionary + { + { Stats.Level, 130 }, + { Stats.MaximumHealth, 400_000 }, + { Stats.MinimumPhysBaseDmg, 2000 }, + { Stats.MaximumPhysBaseDmg, 2500 }, + { Stats.DefenseBase, 5000 }, + { Stats.AttackRatePvm, 2000 }, + { Stats.DefenseRatePvm, 1500 }, + { Stats.PoisonResistance, 40f / 255 }, + { Stats.IceResistance, 40f / 255 }, + { Stats.LightningResistance, 40f / 255 }, + { Stats.FireResistance, 40f / 255 }, + }; + monster.AddAttributes(attributes, this.Context, this.GameConfiguration); + monster.SetGuid(monster.Number); + } + + // Maya Right Hand (#363) + { + var monster = this.Context.CreateNew(); + this.GameConfiguration.Monsters.Add(monster); + monster.Number = 363; + monster.Designation = "Maya (Hand Right)"; + monster.MoveRange = 3; + monster.AttackRange = 5; + monster.ViewRange = 8; + monster.MoveDelay = new TimeSpan(400 * TimeSpan.TicksPerMillisecond); + monster.AttackDelay = new TimeSpan(1600 * TimeSpan.TicksPerMillisecond); + monster.RespawnDelay = new TimeSpan(0); + monster.Attribute = 2; + monster.NumberOfMaximumItemDrops = 3; + // Maya Right Hand uses IceStorm — same AoE ice attack as the left hand. + monster.AttackSkill = this.GameConfiguration.Skills.FirstOrDefault(s => s.Number == (short)SkillNumber.IceStorm); + var attributes = new Dictionary + { + { Stats.Level, 130 }, + { Stats.MaximumHealth, 350_000 }, + { Stats.MinimumPhysBaseDmg, 2000 }, + { Stats.MaximumPhysBaseDmg, 2500 }, + { Stats.DefenseBase, 5000 }, + { Stats.AttackRatePvm, 2100 }, + { Stats.DefenseRatePvm, 1600 }, + { Stats.PoisonResistance, 40f / 255 }, + { Stats.IceResistance, 40f / 255 }, + { Stats.LightningResistance, 40f / 255 }, + { Stats.FireResistance, 40f / 255 }, + }; + monster.AddAttributes(attributes, this.Context, this.GameConfiguration); + monster.SetGuid(monster.Number); + } } -} \ No newline at end of file +}