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
+}