diff --git a/src/GameLogic/IGameContext.cs b/src/GameLogic/IGameContext.cs
index 69486b0ed..b8318c0c4 100644
--- a/src/GameLogic/IGameContext.cs
+++ b/src/GameLogic/IGameContext.cs
@@ -128,6 +128,11 @@ public interface IGameContext
///
IPartyManager PartyManager { get; }
+ ///
+ /// Gets the manager which hosts and tracks the mini game instances of the game.
+ ///
+ IMiniGameManager MiniGames { get; }
+
///
/// Gets the initialized maps which are hosted on this context.
///
@@ -160,11 +165,6 @@ public interface IGameContext
///
ValueTask GetMapAsync(ushort mapId, bool createIfNotExists = true);
- ///
- /// Gets the manager which hosts and tracks the mini game instances of the game.
- ///
- IMiniGameManager MiniGames { get; }
-
///
/// Gets the player object by character name.
///
diff --git a/src/GameLogic/MiniGames/Kanturu/IKanturuEventViewPlugIn.cs b/src/GameLogic/MiniGames/Kanturu/IKanturuEventViewPlugIn.cs
index 483824e7e..cd69fedef 100644
--- a/src/GameLogic/MiniGames/Kanturu/IKanturuEventViewPlugIn.cs
+++ b/src/GameLogic/MiniGames/Kanturu/IKanturuEventViewPlugIn.cs
@@ -24,7 +24,7 @@ public interface IKanturuEventViewPlugIn : IViewPlugIn
///
/// Remaining time. Semantics depend on state:
/// Standby → time until the event opens (client shows minutes).
- /// Tower → time the tower has been open (client shows hours).
+ /// Tower → time until the tower closes (client shows hours).
/// Otherwise zero.
///
ValueTask ShowStateInfoAsync(KanturuState state, byte detailState, bool canEnter, int userCount, TimeSpan remainTime);
diff --git a/src/GameLogic/MiniGames/Kanturu/IKanturuPhaseRunner.cs b/src/GameLogic/MiniGames/Kanturu/IKanturuPhaseRunner.cs
new file mode 100644
index 000000000..a74038054
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/IKanturuPhaseRunner.cs
@@ -0,0 +1,27 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+using System.Threading;
+
+///
+/// Runs one phase of the Kanturu event. Each phase kind has its own implementation,
+/// which the game loop selects through this interface.
+///
+internal interface IKanturuPhaseRunner
+{
+ ///
+ /// Gets the kind of phase this runner executes.
+ ///
+ KanturuPhaseKind Kind { get; }
+
+ ///
+ /// Runs the phase.
+ ///
+ /// The phase to run.
+ /// The cancellation token.
+ /// true when the phase completed; false when its time limit expired.
+ Task RunAsync(KanturuPhaseDefinition phase, CancellationToken cancellationToken);
+}
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuBarrierAreaHelper.cs b/src/GameLogic/MiniGames/Kanturu/KanturuBarrierAreaHelper.cs
new file mode 100644
index 000000000..4c036c747
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuBarrierAreaHelper.cs
@@ -0,0 +1,32 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+///
+/// Enumerates the terrain cells of the Elphis barrier areas.
+///
+internal static class KanturuBarrierAreaHelper
+{
+ ///
+ /// Enumerates every cell covered by the given areas, inclusive.
+ ///
+ /// The terrain areas to expand.
+ /// Every cell covered by the areas.
+ public static IEnumerable<(byte X, byte Y)> EnumerateCells(IEnumerable areas)
+ {
+ // int loop variables: byte would wrap 255 -> 0 and loop forever
+ // when an area touches the map border.
+ foreach (var area in areas)
+ {
+ for (var x = (int)area.StartX; x <= area.EndX; x++)
+ {
+ for (var y = (int)area.StartY; y <= area.EndY; y++)
+ {
+ yield return ((byte)x, (byte)y);
+ }
+ }
+ }
+ }
+}
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuContext.cs b/src/GameLogic/MiniGames/Kanturu/KanturuContext.cs
index 9e34c3f62..7344c5553 100644
--- a/src/GameLogic/MiniGames/Kanturu/KanturuContext.cs
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuContext.cs
@@ -13,7 +13,6 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views.Inventory;
-using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
@@ -25,38 +24,51 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
/// The run of the event is described by a , which is
/// configured at the . This context just executes its
/// one after another, so the event can be adapted
-/// without code changes. describes the original
+/// without code changes. describes the
/// season 6 event: three waves of monsters which each end with a fight against the hands of
/// Maya, then the transition into the Nightmare zone and the boss fight, and finally the Tower
/// of Refinement.
/// Players who die are respawned at Kanturu Relics, which is handled by the safezone map of
/// the event map.
+/// A game created while the tower window is still open skips the phases and only hosts
+/// the Tower of Refinement (see ).
///
public sealed class KanturuContext : MiniGameContext
{
///
/// The detail state which makes the clients hide the in-map HUD. It's the "none" value of
- /// all of the detail state enums.
+ /// all of the detail state enums. Shared with the gateway dialog, which shows the
+ /// standby state instead while refills are accepted.
///
- private const byte HudHiddenDetailState = 0;
+ internal const byte HudHiddenDetailState = 0;
+
+ ///
+ /// The map center, from which the range of the alive-monster query covers the whole map.
+ /// Shared with the Kanturu collaborators which query all monsters of the map.
+ ///
+ internal static readonly Point MapCenter = new(127, 127);
+
+ ///
+ /// How long a tower without any visitor stays alive before it ends itself.
+ ///
+ private static readonly TimeSpan TowerIdleGracePeriod = TimeSpan.FromMinutes(5);
private readonly IMapInitializer _mapInitializer;
+ private readonly IGameContext _gameContext;
private readonly KanturuEventDefinition _definition;
private readonly MonsterDefinition? _nightmareMonsterDefinition;
-
- private KanturuPhaseDefinition? _currentPhase;
- private int _waveKillCount;
- private TaskCompletionSource _phaseComplete = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly KanturuKillTracker _killTracker = new();
+ private readonly KanturuWaveTimer _waveTimer = new();
+ private readonly KanturuMayaWideAttacker _mayaAttacker;
+ private readonly Dictionary _phaseRunners;
+ private readonly TimeSpan _towerOpenDuration;
// Interlocked flags (0 = false, 1 = true) - avoids volatile by using explicit atomic reads/writes.
private int _isVictory;
private int _barrierOpened;
- private int _nightmareTeleporting;
private int _mayaAttacksPaused;
-
- // Nightmare health phase tracking
- private Monster? _nightmareMonster;
- private int _nightmarePhaseIndex;
+ private int _inStandby;
+ private int _everEntered;
///
/// Initializes a new instance of the class.
@@ -73,12 +85,56 @@ public KanturuContext(
: base(key, definition, gameContext, mapInitializer)
{
this._mapInitializer = mapInitializer;
+ this._gameContext = gameContext;
// The definition is resolved once, so that a configuration change doesn't affect a
// running event.
- this._definition = GetEventDefinition(gameContext);
+ this._definition = GetEventDefinition(gameContext, out this._towerOpenDuration);
+ this.TowerEntryPoint = KanturuTowerEntry.GetTowerEntryPoint(this._definition);
+ if (KanturuTowerWindow.GetOpenUntilUtc(gameContext) is { } openUntil && openUntil > DateTime.UtcNow)
+ {
+ // A game created while the tower window is open hosts the tower: the map
+ // must replicate the victory state (open barrier) from the start, because
+ // players warp in before the game starts. The state starts as Tower, so
+ // no Maya battle transition is ever observable on a fresh tower map.
+ this.TowerMode = true;
+ this.ApplyBarrierTerrain();
+ this.CurrentKanturuState = KanturuState.Tower;
+ this.CurrentKanturuDetailState = (byte)KanturuTowerDetailState.Revitalization;
+ this.Logger.LogInformation("Kanturu: hosting tower until {TowerOpenUntilUtc}.", openUntil);
+ }
+
this._nightmareMonsterDefinition = this._definition.Phases
.FirstOrDefault(phase => phase.Kind == KanturuPhaseKind.Nightmare)?.Nightmare?.Monster;
+
+ // One runner per phase kind; the game loop dispatches through this map.
+ this._mayaAttacker = new KanturuMayaWideAttacker(this.Map, action => this.ForEachPlayerAsync(action), this.Logger);
+ var waveRunner = new KanturuMonsterWaveRunner(
+ this.BeginPhaseAsync,
+ this.AnnouncePhaseAsync,
+ this.WaitForPhaseEndAsync,
+ this.RunStandbyAsync);
+ var transitionRunner = new KanturuTransitionRunner(
+ this.ShowKanturuStateAsync,
+ action => this.ForEachPlayerAsync(action),
+ this._killTracker.ClearPhase);
+ var nightmareRunner = new KanturuNightmareRunner(
+ this.BeginPhaseAsync,
+ this.ShowKanturuStateAsync,
+ this.ShowGoldenMessageIfConfiguredAsync,
+ this.ShowLiveMinionCountAsync,
+ this.WaitForPhaseEndAsync,
+ this.RunStandbyAsync,
+ this.WaitForNightmareSpawnAsync,
+ action => this.ForEachPlayerAsync(action),
+ this.SpawnWaveAsync,
+ this.Logger);
+ this._phaseRunners = new Dictionary
+ {
+ [waveRunner.Kind] = waveRunner,
+ [transitionRunner.Kind] = transitionRunner,
+ [nightmareRunner.Kind] = nightmareRunner,
+ };
}
///
@@ -93,11 +149,84 @@ public KanturuContext(
///
public byte CurrentKanturuDetailState { get; private set; }
+ ///
+ /// Gets where tower entrants arrive: the Nightmare zone entry. It's null
+ /// when the event definition configures no transition.
+ ///
+ public Point? TowerEntryPoint { get; }
+
+ ///
+ /// Gets a value indicating whether this game only hosts the Tower of
+ /// Refinement within an already open tower window, without running the event
+ /// phases. It's determined at creation from the open tower window.
+ ///
+ public bool TowerMode { get; private set; }
+
+ ///
+ /// Gets a value indicating whether the map entry requirements are skipped.
+ /// Tower visitors don't need the event entry requirements anymore.
+ ///
+ internal override bool SkipMapEntryRequirements => this.TowerMode || this.CurrentKanturuState == KanturuState.Tower;
+
+ ///
+ /// Gets a value indicating whether players may rejoin the open Tower of Refinement
+ /// or refill the event during an inter-wave standby. Fights can never be joined
+ /// mid-event.
+ ///
+ protected override bool AllowEnterWhilePlaying => this.CurrentKanturuState == KanturuState.Tower
+ || (this.CurrentKanturuState == KanturuState.MayaBattle && Volatile.Read(ref this._inStandby) != 0);
+
+ ///
+ /// Gets the countdown duration after the entrance closed; the entrance closes one
+ /// minute before the game starts. Tower games start immediately.
+ ///
+ protected override TimeSpan CountdownDuration => this.TowerMode ? TimeSpan.Zero : TimeSpan.FromMinutes(1);
+
+ ///
+ /// Gets the minimum duration of the entrance phase; tower games don't need a lobby.
+ ///
+ protected override TimeSpan MinimumEnterDuration => this.TowerMode ? TimeSpan.Zero : base.MinimumEnterDuration;
+
+ ///
+ /// Gets the minimum player count to start the game. A reopened tower starts empty;
+ /// visitors join an already running tower.
+ ///
+ protected override int MinimumPlayerCount => this.TowerMode ? 0 : 1;
+
+ ///
+ /// Tower entrants spawn at the tower entry instead of the event start.
+ ///
+ /// The player which enters.
+ /// The tower entry point, or null to keep the warp target.
+ internal override Point? GetEntrySpawnPosition(Player player)
+ {
+ if (this.TowerMode || this.CurrentKanturuState == KanturuState.Tower)
+ {
+ return this.TowerEntryPoint;
+ }
+
+ return null;
+ }
+
///
protected override async ValueTask OnGameStartAsync(ICollection players)
{
await base.OnGameStartAsync(players).ConfigureAwait(false);
+ _ = Task.Run(() => this.RunRequiredItemWearAsync(this.GameEndedToken), this.GameEndedToken);
+
+ // The flag alone is not enough: it may have been set on a fresh event lobby
+ // by an enter racing a scheduler start, which clears the window first.
+ if (this.TowerMode
+ && KanturuTowerWindow.GetOpenUntilUtc(this._gameContext) is { } until
+ && until > DateTime.UtcNow)
+ {
+ Interlocked.Exchange(ref this._isVictory, 1);
+ await this.AnnounceTowerMapAsync().ConfigureAwait(false);
+ _ = Task.Run(() => this.RunTowerModeAsync(this.GameEndedToken), this.GameEndedToken);
+ return;
+ }
+
// Maya rises from the depths when the battle begins.
if (this._definition.IntroSpawnWaveNumber is { } introWave)
{
@@ -107,13 +236,10 @@ protected override async ValueTask OnGameStartAsync(ICollection players)
await this.ShowGoldenMessageIfConfiguredAsync(this._definition.IntroMessageKey).ConfigureAwait(false);
_ = Task.Run(() => this.RunKanturuGameLoopAsync(this.GameEndedToken), this.GameEndedToken);
- _ = Task.Run(() => this.RunRequiredItemWearAsync(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
+ protected override void OnMonsterDied(object? sender, DeathInformation e)
{
try
{
@@ -124,35 +250,24 @@ protected override async void OnMonsterDied(object? sender, DeathInformation e)
return;
}
- var definition = monster.Definition;
- var phase = this._currentPhase;
- if (phase is null || !phase.CountedMonsters.Any(counted => IsSameMonster(counted, definition)))
+ var killedDefinition = monster.Definition;
+ var result = this._killTracker.RegisterKill(killedDefinition);
+ if (this.IsUnexpectedNightmareDeath(killedDefinition, result))
{
- if (IsSameMonster(this._nightmareMonsterDefinition, definition))
- {
- this.Logger.LogWarning(
- "Kanturu: Nightmare died during phase {Phase}, where it isn't expected. The barrier is NOT opened.",
- phase?.Name ?? "");
- }
-
- return;
+ this.Logger.LogWarning(
+ "Kanturu: Nightmare died during phase {Phase}, where it isn't expected. The barrier is NOT opened.",
+ result.Phase?.Name ?? "");
}
- var killed = Interlocked.Increment(ref this._waveKillCount);
- await this.ShowMonsterUserCountAsync(Math.Max(0, phase.KillTarget - killed), this.PlayerCount).ConfigureAwait(false);
-
- if (phase.Kind == KanturuPhaseKind.Nightmare && IsSameMonster(phase.Nightmare?.Monster, definition))
+ if (!result.Counted && !result.IsNightmarePhase)
{
- // Open the barrier immediately from the death event. Don't wait for the game
- // loop - it may be interrupted by a cancellation of the GameEndedToken before
- // it reaches OpenElphisBarrierAsync.
- await this.OpenElphisBarrierAsync().ConfigureAwait(false);
+ return;
}
- if (killed >= phase.KillTarget)
- {
- this._phaseComplete.TrySetResult();
- }
+ // The handler itself stays synchronous, like the invasion death broadcast:
+ // the client notifications run fire-and-forget on the thread pool instead
+ // of making this an async void method.
+ _ = Task.Run(() => this.HandleMonsterDiedAsync(result.Phase, result));
}
catch (Exception ex)
{
@@ -160,6 +275,28 @@ protected override async void OnMonsterDied(object? sender, DeathInformation e)
}
}
+ ///
+ protected override async ValueTask OnObjectAddedToMapAsync((GameMap Map, ILocateable Object) args)
+ {
+ await base.OnObjectAddedToMapAsync(args).ConfigureAwait(false);
+
+ if (args.Object is Player)
+ {
+ Interlocked.Exchange(ref this._everEntered, 1);
+ }
+
+ // Nothing else broadcasts during the tower, so players joining it late would
+ // miss the tower state. Both broadcasts are idempotent for the other players.
+ // Their position is already correct: tower entrants spawn at the tower entry
+ // through GetEntrySpawnPosition.
+ if (args.Object is Player && this.CurrentKanturuState == KanturuState.Tower)
+ {
+ await this.ShowKanturuStateAsync(this.CurrentKanturuState, this.CurrentKanturuDetailState).ConfigureAwait(false);
+ await this.ShowLiveMinionCountAsync().ConfigureAwait(false);
+ await this.SendBarrierAttributesAsync().ConfigureAwait(false);
+ }
+ }
+
///
protected override async ValueTask GameEndedAsync(ICollection finishers)
{
@@ -181,45 +318,14 @@ await this.ForEachPlayerAsync(player =>
await base.GameEndedAsync(finishers).ConfigureAwait(false);
}
- ///
- /// Gets the equipped items of the player which provide one of the attributes the event map
- /// requires, so the ones without which it couldn't have entered.
- ///
- /// The player whose equipped items are searched.
- /// The requirements of the event map.
- private static IList- GetRequiredItems(Player player, ICollection requirements)
- {
- if (player.Inventory is not { } inventory)
- {
- return [];
- }
-
- return inventory.EquippedItems
- .Where(item => item.Definition?.BasePowerUpAttributes
- .Any(powerUp => requirements.Any(requirement => requirement.Attribute == powerUp.TargetAttribute)) is true)
- .ToList();
- }
-
- private static KanturuEventDefinition GetEventDefinition(IGameContext gameContext)
+ private static KanturuEventDefinition GetEventDefinition(IGameContext gameContext, out TimeSpan towerOpenDuration)
{
var startPlugIn = gameContext.PlugInManager
.GetStrategy(MiniGameType.Kanturu);
- if (startPlugIn is ISupportCustomConfiguration { Configuration.EventDefinition: { } definition })
- {
- return definition;
- }
-
- return KanturuEventDefinition.CreateDefault(gameContext.Configuration);
- }
-
- ///
- /// Determines whether the monster definitions describe the same monster. They're compared
- /// by their number, because the configured definition may be a different instance than the
- /// one of the spawned monster.
- ///
- private static bool IsSameMonster(MonsterDefinition? first, MonsterDefinition? second)
- {
- return first is not null && second is not null && first.Number == second.Number;
+ var configuration = (startPlugIn as ISupportCustomConfiguration)?.Configuration;
+ var definition = configuration?.EventDefinition ?? KanturuEventDefinition.CreateDefault(gameContext.Configuration);
+ towerOpenDuration = configuration?.TowerOpenDuration ?? definition.TowerOfRefinementDuration;
+ return definition;
}
private async Task RunKanturuGameLoopAsync(CancellationToken ct)
@@ -235,7 +341,12 @@ private async Task RunKanturuGameLoopAsync(CancellationToken ct)
using var mayaAttackCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
if (this._definition.MayaAttackInterval > TimeSpan.Zero)
{
- _ = Task.Run(() => this.RunMayaWideAreaAttacksAsync(mayaAttackCts.Token), mayaAttackCts.Token);
+ _ = Task.Run(
+ () => this._mayaAttacker.RunAsync(
+ this._definition.MayaAttackInterval,
+ () => Volatile.Read(ref this._mayaAttacksPaused) != 0,
+ mayaAttackCts.Token),
+ mayaAttackCts.Token);
}
foreach (var phase in this._definition.Phases)
@@ -247,18 +358,24 @@ private async Task RunKanturuGameLoopAsync(CancellationToken ct)
}
this.Logger.LogDebug("Kanturu: starting phase {Phase}.", phase.Name);
- await this.RunPhaseAsync(phase, ct).ConfigureAwait(false);
+ if (!await this.RunPhaseAsync(phase, ct).ConfigureAwait(false))
+ {
+ // The wave failed (its time limit expired) - the event is lost.
+ this.FinishEvent();
+ return;
+ }
}
Interlocked.Exchange(ref this._isVictory, 1);
- this._currentPhase = null;
+ this._killTracker.ClearPhase();
// The fire-and-forget call from OnMonsterDied already opened the barrier; this is
// a fallback for the case that no boss death was registered. The Interlocked guard
// in OpenElphisBarrierAsync makes sure that it only executes once.
await this.OpenElphisBarrierAsync().ConfigureAwait(false);
- await this.RunTowerOfRefinementAsync(ct).ConfigureAwait(false);
+ await this.ShowGoldenMessageIfConfiguredAsync(this._definition.TowerConqueredMessageKey).ConfigureAwait(false);
+ await this.RunTowerOfRefinementAsync(this._towerOpenDuration, this._definition.TowerClosingWarningOffset, ct).ConfigureAwait(false);
this.FinishEvent();
}
@@ -272,73 +389,28 @@ private async Task RunKanturuGameLoopAsync(CancellationToken ct)
}
}
- private Task RunPhaseAsync(KanturuPhaseDefinition phase, CancellationToken ct)
+ private Task RunPhaseAsync(KanturuPhaseDefinition phase, CancellationToken ct)
{
- return phase.Kind switch
+ if (this._phaseRunners.TryGetValue(phase.Kind, out var runner))
{
- KanturuPhaseKind.Transition => this.RunTransitionPhaseAsync(phase, ct),
- KanturuPhaseKind.Nightmare => this.RunNightmarePhaseAsync(phase, ct),
- _ => this.RunMonsterWavePhaseAsync(phase, ct),
- };
- }
-
- private async Task RunMonsterWavePhaseAsync(KanturuPhaseDefinition phase, CancellationToken ct)
- {
- await this.BeginPhaseAsync(phase, ct).ConfigureAwait(false);
- await this.AnnouncePhaseAsync(phase).ConfigureAwait(false);
- await this.WaitForPhaseEndAsync(phase, ct).ConfigureAwait(false);
- await this.RunStandbyAsync(phase, ct).ConfigureAwait(false);
- }
-
- ///
- /// Runs the transition into the Nightmare zone.
- ///
- ///
- /// The detail state of the phase ()
- /// triggers the full cinematic on the client: the camera flies to the Maya room, her body
- /// plays its explosion animation and then the hero falls through the floor. Only after
- /// that the players are moved, so the movement isn't visible during the animation.
- ///
- private async Task RunTransitionPhaseAsync(KanturuPhaseDefinition phase, CancellationToken ct)
- {
- var transition = phase.Transition ?? new KanturuTransitionDefinition();
- this._currentPhase = null;
-
- await this.ShowKanturuStateAsync(phase.State, phase.DetailState).ConfigureAwait(false);
-
- // The cinematic is never cancelled in the middle, so it uses no cancellation token.
- await Task.Delay(transition.CinematicDuration).ConfigureAwait(false);
- ct.ThrowIfCancellationRequested();
-
- // It's the same map, so a move is sufficient.
- var entryPoint = new Point(transition.EntryPointX, transition.EntryPointY);
- await this.ForEachPlayerAsync(player => player.MoveAsync(entryPoint).AsTask()).ConfigureAwait(false);
+ return runner.RunAsync(phase, ct);
+ }
- // The warp animation has to be played after the move, so it's rendered at the entry
- // point and not at the Maya battlefield. It also locks the player input briefly,
- // which prevents movement and attacks during the scene transition.
- await Task.Delay(transition.WarpAnimationDelay).ConfigureAwait(false);
- await this.ForEachPlayerAsync(player =>
- player.InvokeViewPlugInAsync(p =>
- p.MapChangeFailedAsync()).AsTask()).ConfigureAwait(false);
+ this.Logger.LogWarning("Kanturu: no runner for phase kind {PhaseKind}, running it as a monster wave.", phase.Kind);
+ return this._phaseRunners[KanturuPhaseKind.MonsterWave].RunAsync(phase, ct);
}
///
- /// Runs the boss fight. The boss teleports and recovers its health at the configured
- /// .
+ /// Waits for the Nightmare boss to spawn, by capturing it from .
+ /// Infrastructure for ; returns null on timeout.
///
- private async Task RunNightmarePhaseAsync(KanturuPhaseDefinition phase, CancellationToken ct)
+ private async Task WaitForNightmareSpawnAsync(KanturuNightmareDefinition nightmare, CancellationToken ct)
{
- var nightmare = phase.Nightmare ?? new KanturuNightmareDefinition();
- this._nightmarePhaseIndex = 0;
- this._nightmareMonster = null;
-
- // Subscribe to ObjectAdded to capture the boss as soon as it spawns.
var nightmareFound = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
ValueTask OnObjectAddedAsync((GameMap Map, ILocateable Object) args)
{
- if (args.Object is Monster monster && IsSameMonster(nightmare.Monster, monster.Definition))
+ if (args.Object is Monster monster && KanturuMonsterComparer.IsSameMonster(nightmare.Monster, monster.Definition))
{
nightmareFound.TrySetResult(monster);
}
@@ -349,66 +421,28 @@ ValueTask OnObjectAddedAsync((GameMap Map, ILocateable Object) args)
this.Map.ObjectAdded += OnObjectAddedAsync;
try
{
- await this.BeginPhaseAsync(phase, ct).ConfigureAwait(false);
- this._nightmareMonster = await nightmareFound.Task
- .WaitAsync(nightmare.SpawnTimeout, ct)
- .ConfigureAwait(false);
+ return await nightmareFound.Task.WaitAsync(nightmare.SpawnTimeout, ct).ConfigureAwait(false);
}
catch (TimeoutException)
{
- this.Logger.LogWarning(
- "Kanturu: the Nightmare monster didn't spawn within {Timeout} - its health phases are disabled.",
- nightmare.SpawnTimeout);
+ return null;
}
finally
{
this.Map.ObjectAdded -= OnObjectAddedAsync;
}
-
- // Switch to the battle state, so the clients show the boss HUD.
- await this.ShowKanturuStateAsync(phase.State, nightmare.BattleDetailState).ConfigureAwait(false);
- await this.AnnouncePhaseAsync(phase).ConfigureAwait(false);
-
- // Both loops are linked to the same token source, so they stop together as soon as the
- // boss died or the game was cancelled.
- using var bossCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
- var healthMonitor = Task.Run(() => this.MonitorNightmareHealthAsync(nightmare, bossCts.Token), bossCts.Token);
- var specialAttacks = Task.Run(() => this.RunNightmareSpecialAttacksAsync(nightmare, bossCts.Token), bossCts.Token);
-
- await this.WaitForPhaseEndAsync(phase, ct).ConfigureAwait(false);
-
- await bossCts.CancelAsync().ConfigureAwait(false);
-
- try
- {
- await healthMonitor.ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // Expected when the phase ends.
- }
-
- try
- {
- await specialAttacks.ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // Expected when the phase ends.
- }
-
- await this.RunStandbyAsync(phase, ct).ConfigureAwait(false);
}
private async Task BeginPhaseAsync(KanturuPhaseDefinition phase, CancellationToken ct)
{
- Interlocked.Exchange(ref this._waveKillCount, 0);
- this._phaseComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- this._currentPhase = phase;
+ this._killTracker.BeginPhase(phase);
await this.ShowKanturuStateAsync(phase.State, phase.DetailState).ConfigureAwait(false);
- if (phase.TimeLimit is { } timeLimit)
+ // A following phase of a wave shows the remaining shared time instead of a fresh timer.
+ // An already expired remainder is not shown; the wait below fails the wave at once.
+ if (this._waveTimer.GetEffectiveLimit(phase) is { } timeLimit
+ && (timeLimit > TimeSpan.Zero || phase.TimeLimitGroup is null))
{
await this.ShowTimeLimitToAllAsync(timeLimit).ConfigureAwait(false);
}
@@ -417,10 +451,22 @@ private async Task BeginPhaseAsync(KanturuPhaseDefinition phase, CancellationTok
if (phase.SpawnWaveNumber is { } waveNumber)
{
- await this._mapInitializer.InitializeNpcsOnWaveStartAsync(this.Map, this, waveNumber).ConfigureAwait(false);
+ await this.SpawnWaveAsync(waveNumber, ct).ConfigureAwait(false);
}
}
+ ///
+ /// Spawns a configured monster wave on the event map, e.g. a phase wave or the
+ /// Nightmare summons.
+ ///
+ /// The number of the started spawn wave.
+ /// The cancellation token.
+ private async Task SpawnWaveAsync(byte waveNumber, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await this._mapInitializer.InitializeNpcsOnWaveStartAsync(this.Map, this, waveNumber).ConfigureAwait(false);
+ }
+
private async Task AnnouncePhaseAsync(KanturuPhaseDefinition phase)
{
// Broadcast the initial monster count, so the HUD shows the correct number from the start.
@@ -428,16 +474,50 @@ private async Task AnnouncePhaseAsync(KanturuPhaseDefinition phase)
await this.ShowGoldenMessageIfConfiguredAsync(phase.StartMessageKey).ConfigureAwait(false);
}
- private async Task WaitForPhaseEndAsync(KanturuPhaseDefinition phase, CancellationToken ct)
+ private async Task WaitForPhaseEndAsync(KanturuPhaseDefinition phase, CancellationToken ct)
{
if (phase.Duration is { } duration)
{
await this.DelayAsync(duration, ct).ConfigureAwait(false);
+ return true;
}
- else
+
+ var killWait = this._killTracker.PhaseCompleted.WaitAsync(ct);
+ var timeLimit = this._waveTimer.GetEffectiveLimit(phase);
+ if (timeLimit is not { } limit || (limit <= TimeSpan.Zero && phase.TimeLimitGroup is null))
+ {
+ await killWait.ConfigureAwait(false);
+ return true;
+ }
+
+ if (limit <= TimeSpan.Zero)
+ {
+ // The shared wave clock already expired during an earlier phase of the group.
+ return false;
+ }
+
+ // A wave fails when its time limit expires before the kill target is reached.
+ // A skipped wait (game master) passes the wave instead. When both finish at
+ // once, the kills win: failing an actually completed wave would be unfair.
+ // Both racers are observed: the loser would otherwise surface an unobserved
+ // OperationCanceledException when the game ends.
+ var timeoutWait = this.DelayWithSkipAsync(limit, ct);
+ Observe(killWait);
+ Observe(timeoutWait);
+ var winner = await Task.WhenAny(killWait, timeoutWait).ConfigureAwait(false);
+ if (winner == killWait)
{
- await this._phaseComplete.Task.WaitAsync(ct).ConfigureAwait(false);
+ await killWait.ConfigureAwait(false);
+ return true;
}
+
+ return await timeoutWait.ConfigureAwait(false) || killWait.IsCompletedSuccessfully;
+
+ static void Observe(Task task) => _ = task.ContinueWith(
+ static faulted => _ = faulted.Exception,
+ CancellationToken.None,
+ TaskContinuationOptions.OnlyOnFaulted,
+ TaskScheduler.Default);
}
///
@@ -452,6 +532,7 @@ private async Task RunStandbyAsync(KanturuPhaseDefinition phase, CancellationTok
}
Interlocked.Exchange(ref this._mayaAttacksPaused, 1);
+ Interlocked.Exchange(ref this._inStandby, 1);
try
{
await this.ShowKanturuStateAsync(phase.State, HudHiddenDetailState).ConfigureAwait(false);
@@ -461,94 +542,50 @@ private async Task RunStandbyAsync(KanturuPhaseDefinition phase, CancellationTok
finally
{
Interlocked.Exchange(ref this._mayaAttacksPaused, 0);
+ Interlocked.Exchange(ref this._inStandby, 0);
}
}
///
- /// Polls the health of the boss and triggers the teleport of the next health phase.
+ /// Broadcasts the kill count change of a monster death and opens the Elphis barrier
+ /// when the Nightmare boss died. Runs fire-and-forget from .
///
- private async Task MonitorNightmareHealthAsync(KanturuNightmareDefinition nightmare, CancellationToken ct)
+ /// The phase which was current when the monster died.
+ /// The outcome of the kill registration.
+ private async Task HandleMonsterDiedAsync(KanturuPhaseDefinition? phase, KanturuKillResult result)
{
- if (nightmare.HpPhases.Count == 0 || nightmare.HealthCheckInterval <= TimeSpan.Zero)
- {
- return;
- }
-
- while (!ct.IsCancellationRequested)
+ try
{
- await Task.Delay(nightmare.HealthCheckInterval, ct).ConfigureAwait(false);
-
- // Don't check the health while a teleport is in progress: the teleport restores the
- // health itself, so reading it in the meantime would give a stale (low) value and
- // trigger the next phase too early.
- if (Volatile.Read(ref this._nightmareTeleporting) != 0)
+ if (result.IsNightmarePhase)
{
- continue;
+ await this.ShowLiveMinionCountAsync().ConfigureAwait(false);
}
-
- if (this._nightmareMonster is not { IsAlive: true } monster)
+ else if (result.Counted && phase is not null)
{
- continue;
+ await this.ShowMonsterUserCountAsync(Math.Max(0, phase.KillTarget - result.KillCount), this.PlayerCount).ConfigureAwait(false);
}
-
- var maximumHealth = monster.Attributes[Stats.MaximumHealth];
- var healthPercentage = maximumHealth > 0 ? monster.Health * 100f / maximumHealth : 100f;
-
- var targetPhaseIndex = 0;
- for (var i = 0; i < nightmare.HpPhases.Count; i++)
+ else
{
- if (healthPercentage < nightmare.HpPhases[i].HealthPercentage)
- {
- targetPhaseIndex = i + 1;
- }
+ // Uncounted kills outside the Nightmare phase change nothing.
}
- if (targetPhaseIndex > this._nightmarePhaseIndex)
+ if (result.NightmareBossKilled)
{
- this._nightmarePhaseIndex = targetPhaseIndex;
- await this.ExecuteNightmareTeleportAsync(monster, nightmare, nightmare.HpPhases[targetPhaseIndex - 1], ct)
- .ConfigureAwait(false);
+ // Open the barrier immediately from the death event. Don't wait for the game
+ // loop - it may be interrupted by a cancellation of the GameEndedToken before
+ // it reaches OpenElphisBarrierAsync.
+ await this.OpenElphisBarrierAsync().ConfigureAwait(false);
}
}
- }
-
- ///
- /// Teleports the boss to the position of the given health phase and restores its health.
- ///
- ///
- /// The guard makes sure that the health monitor can't
- /// trigger the next phase while the teleport is running.
- ///
- private async Task ExecuteNightmareTeleportAsync(Monster monster, KanturuNightmareDefinition nightmare, KanturuNightmareHpPhase hpPhase, CancellationToken ct)
- {
- // The boss may have died between the health check and this call.
- if (!monster.IsAlive)
+ catch (Exception ex)
{
- return;
+ this.Logger.LogError(ex, "Unexpected error in OnMonsterDied.");
}
+ }
- Interlocked.Exchange(ref this._nightmareTeleporting, 1);
- try
- {
- // Restore the health first, so that damage during the animation can't kill the boss.
- // Otherwise a simultaneous hit could drop its health to 0 and cause a death event.
- monster.Health = (int)monster.Attributes[Stats.MaximumHealth];
-
- // A short pause, so the clients can process the health update before the teleport.
- await Task.Delay(nightmare.TeleportDelay).ConfigureAwait(false);
- ct.ThrowIfCancellationRequested();
-
- await monster.MoveAsync(new Point(hpPhase.TeleportTargetX, hpPhase.TeleportTargetY)).ConfigureAwait(false);
-
- // Restore the health a second time, to cover the hits which landed in the meantime.
- monster.Health = (int)monster.Attributes[Stats.MaximumHealth];
-
- await this.ShowGoldenMessageIfConfiguredAsync(hpPhase.MessageKey).ConfigureAwait(false);
- }
- finally
- {
- Interlocked.Exchange(ref this._nightmareTeleporting, 0);
- }
+ private bool IsUnexpectedNightmareDeath(MonsterDefinition? killedDefinition, KanturuKillResult result)
+ {
+ return !result.Counted && KanturuMonsterComparer.IsSameMonster(this._nightmareMonsterDefinition, killedDefinition);
}
///
@@ -569,6 +606,12 @@ private async ValueTask OpenElphisBarrierAsync()
this.Logger.LogInformation("Kanturu: opening the barrier to the Elphis area.");
+ // Persist the open window first, so it survives a server restart even if the
+ // game ends before the tower closes.
+ var towerOpenUntil = DateTime.UtcNow + this._towerOpenDuration;
+ await KanturuTowerWindow.SetOpenUntilUtcAsync(this._gameContext, towerOpenUntil, this.Logger).ConfigureAwait(false);
+ this.Logger.LogInformation("Kanturu: tower open until {TowerOpenUntilUtc}.", towerOpenUntil);
+
await this.ShowGoldenMessageIfConfiguredAsync(this._definition.BarrierOpeningMessageKey).ConfigureAwait(false);
await this.ShowMonsterUserCountAsync(0, this.PlayerCount).ConfigureAwait(false);
@@ -590,20 +633,17 @@ await this.ForEachPlayerAsync(player =>
// formerly blocked cells as passable. The barrier rect mixes walls and holes,
// so it explicitly opts into opening the whole tile instead of only removing
// the configured attribute.
- var terrain = this.Map.Terrain;
- foreach (var area in this._definition.BarrierAreas)
- {
- for (int x = area.StartX; x <= area.EndX; x++)
- {
- for (int y = area.StartY; y <= area.EndY; y++)
- {
- terrain.ApplyTerrainAttribute((byte)x, (byte)y, TerrainAttributeType.NoGround, false, openArea: true);
- }
- }
- }
+ this.ApplyBarrierTerrain();
+
+ await this.SendBarrierAttributesAsync().ConfigureAwait(false);
+ }
- // Additionally send the terrain attribute change as a fallback: if the terrain file of
- // the opened barrier is missing at a client, this packet still clears the attribute.
+ ///
+ /// Sends the terrain attribute change as a fallback: if the terrain file of
+ /// the opened barrier is missing at a client, this packet still clears the attribute.
+ ///
+ private async ValueTask SendBarrierAttributesAsync()
+ {
var areas = this._definition.BarrierAreas
.Select(area => (area.StartX, area.StartY, area.EndX, area.EndY))
.ToList();
@@ -617,105 +657,110 @@ await this.ForEachPlayerAsync(player =>
}
///
- /// Keeps the map open as the Tower of Refinement after the boss has been defeated.
+ /// Removes the barrier attribute from the server walk map, so the path finder and
+ /// the movement checks treat the formerly blocked cells as passable.
///
- private async Task RunTowerOfRefinementAsync(CancellationToken ct)
+ private void ApplyBarrierTerrain()
{
- await this.ShowGoldenMessageIfConfiguredAsync(this._definition.TowerConqueredMessageKey).ConfigureAwait(false);
-
- var duration = this._definition.TowerOfRefinementDuration;
- var warningOffset = this._definition.TowerClosingWarningOffset;
-
- // The delays don't use the token, so they aren't cancelled when all current players
- // leave while new ones might still arrive.
- if (duration > warningOffset)
- {
- await Task.Delay(duration - warningOffset).ConfigureAwait(false);
- ct.ThrowIfCancellationRequested();
-
- await this.ShowGoldenMessageIfConfiguredAsync(this._definition.TowerClosingWarningMessageKey).ConfigureAwait(false);
-
- await Task.Delay(warningOffset).ConfigureAwait(false);
- ct.ThrowIfCancellationRequested();
- }
- else if (duration > TimeSpan.Zero)
+ var terrain = this.Map.Terrain;
+ foreach (var (x, y) in KanturuBarrierAreaHelper.EnumerateCells(this._definition.BarrierAreas))
{
- await Task.Delay(duration).ConfigureAwait(false);
- ct.ThrowIfCancellationRequested();
+ terrain.ApplyTerrainAttribute(x, y, TerrainAttributeType.NoGround, false, openArea: true);
}
+ }
- await this.ShowKanturuStateAsync(KanturuState.Tower, (byte)KanturuTowerDetailState.Notify).ConfigureAwait(false);
- await this.ShowGoldenMessageIfConfiguredAsync(this._definition.TowerClosedMessageKey).ConfigureAwait(false);
- await this.ShowKanturuStateAsync(KanturuState.Tower, (byte)KanturuTowerDetailState.Close).ConfigureAwait(false);
+ ///
+ /// Announces an already-open tower on a fresh map, without battle overlay or cinematic.
+ /// The barrier itself is opened at creation, so players warping in before the game
+ /// starts never face a closed barrier.
+ ///
+ private async ValueTask AnnounceTowerMapAsync()
+ {
+ await this.ShowKanturuStateAsync(KanturuState.Tower, (byte)KanturuTowerDetailState.Revitalization).ConfigureAwait(false);
+ await this.SendBarrierAttributesAsync().ConfigureAwait(false);
}
///
- /// Periodically broadcasts the wide area attack of Maya, alternating between the storm and
- /// the stone rain animation.
+ /// Hosts the tower for the remaining open window, then finishes the event.
+ /// A tower nobody enters is ended after a grace period instead of idling the
+ /// whole window: re-entry recreates it while the window lasts.
///
- private async Task RunMayaWideAreaAttacksAsync(CancellationToken ct)
+ /// The cancellation token.
+ private async Task RunTowerModeAsync(CancellationToken ct)
{
- var isStorm = true;
- while (!ct.IsCancellationRequested)
+ try
{
- try
+ if (Volatile.Read(ref this._everEntered) == 0)
{
- await Task.Delay(this._definition.MayaAttackInterval, ct).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- break;
+ await this.DelayTowerPhaseAsync(TowerIdleGracePeriod, ct).ConfigureAwait(false);
+ if (Volatile.Read(ref this._everEntered) == 0)
+ {
+ this.FinishEvent();
+ return;
+ }
}
- if (Volatile.Read(ref this._mayaAttacksPaused) == 0)
+ var remaining = TimeSpan.Zero;
+ if (KanturuTowerWindow.GetOpenUntilUtc(this._gameContext) is { } until)
{
- var showStorm = isStorm;
- await this.ForEachPlayerAsync(player =>
- player.InvokeViewPlugInAsync(p =>
- p.ShowMayaWideAreaAttackAsync(showStorm)).AsTask()).ConfigureAwait(false);
+ remaining = until - DateTime.UtcNow;
+ if (remaining < TimeSpan.Zero)
+ {
+ remaining = TimeSpan.Zero;
+ }
}
- isStorm = !isStorm;
+ await this.RunTowerOfRefinementAsync(remaining, this._definition.TowerClosingWarningOffset, ct).ConfigureAwait(false);
+
+ this.FinishEvent();
+ }
+ catch (OperationCanceledException)
+ {
+ // Game ended externally - treated as closed tower.
+ }
+ catch (Exception ex)
+ {
+ this.Logger.LogError(ex, "Unexpected error in Kanturu tower mode.");
}
}
///
- /// Periodically broadcasts the special attack animation of the boss to all players of the map.
+ /// Keeps the map open as the Tower of Refinement after the boss has been defeated.
///
- private async Task RunNightmareSpecialAttacksAsync(KanturuNightmareDefinition nightmare, CancellationToken ct)
+ /// How long the tower stays open.
+ /// How long before the end the closing warning is shown.
+ /// The cancellation token.
+ private async Task RunTowerOfRefinementAsync(TimeSpan duration, TimeSpan warningOffset, CancellationToken ct)
{
- if (nightmare.SpecialAttackInterval <= TimeSpan.Zero)
+ // An empty tower ends with the game: re-entry recreates it while the window lasts.
+ if (duration > warningOffset)
{
- return;
+ await this.DelayTowerPhaseAsync(duration - warningOffset, ct).ConfigureAwait(false);
+ await this.ShowGoldenMessageIfConfiguredAsync(this._definition.TowerClosingWarningMessageKey).ConfigureAwait(false);
+ await this.DelayTowerPhaseAsync(warningOffset, ct).ConfigureAwait(false);
}
-
- while (!ct.IsCancellationRequested)
+ else
{
- try
- {
- await Task.Delay(nightmare.SpecialAttackInterval, ct).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- break;
- }
+ await this.DelayTowerPhaseAsync(duration, ct).ConfigureAwait(false);
+ }
- if (this._nightmareMonster is not { IsAlive: true } monster)
- {
- break;
- }
+ await this.ShowKanturuStateAsync(KanturuState.Tower, (byte)KanturuTowerDetailState.Notify).ConfigureAwait(false);
+ await this.ShowGoldenMessageIfConfiguredAsync(this._definition.TowerClosedMessageKey).ConfigureAwait(false);
+ await this.ShowKanturuStateAsync(KanturuState.Tower, (byte)KanturuTowerDetailState.Close).ConfigureAwait(false);
- // Skip during a teleport, to avoid conflicting animations.
- if (Volatile.Read(ref this._nightmareTeleporting) != 0)
- {
- continue;
- }
+ // The window is consumed; a new one starts with the next victory.
+ await KanturuTowerWindow.SetOpenUntilUtcAsync(this._gameContext, null, this.Logger).ConfigureAwait(false);
+ }
- await this.ForEachPlayerAsync(player =>
- player.InvokeViewPlugInAsync(p =>
- p.ShowSkillAnimationAsync(monster, null, nightmare.SpecialAttackSkillNumber, true)).AsTask())
- .ConfigureAwait(false);
+ private async Task DelayTowerPhaseAsync(TimeSpan duration, CancellationToken ct)
+ {
+ if (duration <= TimeSpan.Zero)
+ {
+ return;
}
+
+ await Task.Delay(duration, ct).ConfigureAwait(false);
+ ct.ThrowIfCancellationRequested();
}
///
@@ -763,23 +808,30 @@ private async Task WearRequiredItemsAsync()
// it holds a reader lock which the removal from the map would wait for as a writer.
var destroyedItems = new ConcurrentBag<(Player Player, Item Item)>();
- await this.ForEachPlayerAsync(async player =>
+ await this.ForEachPlayerAsync(player => this.WearPlayerRequiredItemsAsync(player, requirements, destroyedItems)).ConfigureAwait(false);
+
+ await this.RemovePlayersWithDestroyedItemsAsync(destroyedItems).ConfigureAwait(false);
+ }
+
+ private async Task WearPlayerRequiredItemsAsync(Player player, ICollection requirements, ConcurrentBag<(Player Player, Item Item)> destroyedItems)
+ {
+ foreach (var item in KanturuRequiredItemHelper.GetRequiredItems(player, requirements))
{
- foreach (var item in GetRequiredItems(player, requirements))
+ if (item.DecreaseDurability(this._definition.RequiredItemDurabilityLoss))
{
- if (item.DecreaseDurability(this._definition.RequiredItemDurabilityLoss))
- {
- await player.InvokeViewPlugInAsync(p =>
- p.ItemDurabilityChangedAsync(item, false)).ConfigureAwait(false);
- }
+ await player.InvokeViewPlugInAsync(p =>
+ p.ItemDurabilityChangedAsync(item, false)).ConfigureAwait(false);
+ }
- if (item.Durability <= 0)
- {
- destroyedItems.Add((player, item));
- }
+ if (item.Durability <= 0)
+ {
+ destroyedItems.Add((player, item));
}
- }).ConfigureAwait(false);
+ }
+ }
+ private async Task RemovePlayersWithDestroyedItemsAsync(ConcurrentBag<(Player Player, Item Item)> destroyedItems)
+ {
foreach (var (player, item) in destroyedItems)
{
try
@@ -842,6 +894,26 @@ private ValueTask ShowMonsterUserCountAsync(int monsterCount, int userCount)
p.ShowMonsterUserCountAsync(monsterCount, userCount)).AsTask());
}
+ ///
+ /// Broadcasts the currently alive minion count during the Nightmare fight
+ /// instead of counting down a kill target. The Nightmare boss
+ /// itself is not counted: when all minions are dead while it is still alive, the
+ /// HUD shows 0.
+ ///
+ private async ValueTask ShowLiveMinionCountAsync()
+ {
+ // The range covers the whole map from its center.
+ var aliveCount = this.Map.GetAttackablesInRange(MapCenter, byte.MaxValue)
+ .OfType()
+ .Count(monster => monster.IsAlive && !this.IsNightmare(monster));
+ await this.ShowMonsterUserCountAsync(aliveCount, this.PlayerCount).ConfigureAwait(false);
+ }
+
+ private bool IsNightmare(Monster monster)
+ {
+ return KanturuMonsterComparer.IsSameMonster(this._nightmareMonsterDefinition, monster.Definition);
+ }
+
private ValueTask ShowTimeLimitToAllAsync(TimeSpan timeLimit)
{
return this.ForEachPlayerAsync(player =>
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuEventDefinition.cs b/src/GameLogic/MiniGames/Kanturu/KanturuEventDefinition.cs
index 72a7c97e0..03d1c73b4 100644
--- a/src/GameLogic/MiniGames/Kanturu/KanturuEventDefinition.cs
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuEventDefinition.cs
@@ -5,6 +5,7 @@
namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Properties;
///
@@ -14,7 +15,7 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
///
/// It's configured at the Kanturu start plug-in, see
/// MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks.KanturuStartConfiguration.
-/// The values of describe the original season 6 event.
+/// The values of describe the season 6 event.
///
public class KanturuEventDefinition
{
@@ -22,6 +23,8 @@ public class KanturuEventDefinition
private const short MayaRightHandNumber = 363;
+ private const short MayaBodyNumber = 364;
+
private const short NightmareNumber = 361;
private const short BladeHunterNumber = 354;
@@ -118,7 +121,11 @@ public class KanturuEventDefinition
/// Gets or sets how long the Tower of Refinement stays open after the Nightmare boss has
/// been defeated. Set it to to skip the tower phase.
///
- public TimeSpan TowerOfRefinementDuration { get; set; } = TimeSpan.FromHours(1);
+ ///
+ /// When the event runs through the start plug-in,
+ /// takes precedence over this value.
+ ///
+ public TimeSpan TowerOfRefinementDuration { get; set; } = TimeSpan.FromHours(23);
///
/// Gets or sets how long before the end of the the
@@ -152,13 +159,24 @@ public class KanturuEventDefinition
public string? DefeatMessageKey { get; set; }
///
- /// Creates the definition of the original season 6 event.
+ /// Gets the monster numbers of Maya (body and hands), which deal the wide area
+ /// attacks of the Maya phases.
+ ///
+ internal static IReadOnlySet MayaMonsterNumbers { get; } = new HashSet
+ {
+ MayaBodyNumber,
+ MayaLeftHandNumber,
+ MayaRightHandNumber,
+ };
+
+ ///
+ /// Creates the default definition of the Kanturu event.
///
///
/// The game configuration, from which the monsters of the event are resolved. Monsters
/// which it doesn't contain are left out.
///
- /// The definition of the original season 6 event.
+ /// The default definition of the Kanturu event.
public static KanturuEventDefinition CreateDefault(GameConfiguration gameConfiguration)
{
IList Monsters(params short[] monsterNumbers) => monsterNumbers
@@ -181,7 +199,7 @@ IList Monsters(params short[] monsterNumbers) => monsterNumbe
BarrierOpeningMessageKey = nameof(PlayerMessage.KanturuBarrierOpening),
VictoryMessageKey = nameof(PlayerMessage.KanturuVictory),
DefeatMessageKey = nameof(PlayerMessage.KanturuDefeat),
- TowerOfRefinementDuration = TimeSpan.FromHours(1),
+ TowerOfRefinementDuration = TimeSpan.FromHours(23),
TowerClosingWarningOffset = TimeSpan.FromMinutes(5),
TowerConqueredMessageKey = nameof(PlayerMessage.KanturuTowerConquered),
TowerClosingWarningMessageKey = nameof(PlayerMessage.KanturuTowerClosingWarning),
@@ -198,7 +216,8 @@ IList Monsters(params short[] monsterNumbers) => monsterNumbe
Kind = KanturuPhaseKind.MonsterWave,
State = KanturuState.MayaBattle,
DetailState = (byte)KanturuMayaDetailState.Monster1,
- TimeLimit = TimeSpan.FromMinutes(10),
+ TimeLimit = TimeSpan.FromMinutes(15),
+ TimeLimitGroup = KanturuWaveGroup.MayaLeftHand,
SpawnWaveNumber = 1,
KillTarget = 40,
CountedMonsters = Monsters(BladeHunterNumber, DreadfearNumber),
@@ -210,6 +229,7 @@ IList Monsters(params short[] monsterNumbers) => monsterNumbe
Kind = KanturuPhaseKind.MonsterWave,
State = KanturuState.MayaBattle,
DetailState = (byte)KanturuMayaDetailState.Maya1,
+ TimeLimitGroup = KanturuWaveGroup.MayaLeftHand,
SpawnWaveNumber = 2,
KillTarget = 1,
CountedMonsters = Monsters(MayaLeftHandNumber),
@@ -223,7 +243,8 @@ IList Monsters(params short[] monsterNumbers) => monsterNumbe
Kind = KanturuPhaseKind.MonsterWave,
State = KanturuState.MayaBattle,
DetailState = (byte)KanturuMayaDetailState.Monster2,
- TimeLimit = TimeSpan.FromMinutes(10),
+ TimeLimit = TimeSpan.FromMinutes(15),
+ TimeLimitGroup = KanturuWaveGroup.MayaRightHand,
SpawnWaveNumber = 3,
KillTarget = 40,
CountedMonsters = Monsters(BladeHunterNumber, DreadfearNumber),
@@ -235,6 +256,7 @@ IList Monsters(params short[] monsterNumbers) => monsterNumbe
Kind = KanturuPhaseKind.MonsterWave,
State = KanturuState.MayaBattle,
DetailState = (byte)KanturuMayaDetailState.Maya2,
+ TimeLimitGroup = KanturuWaveGroup.MayaRightHand,
SpawnWaveNumber = 4,
KillTarget = 1,
CountedMonsters = Monsters(MayaRightHandNumber),
@@ -248,7 +270,8 @@ IList Monsters(params short[] monsterNumbers) => monsterNumbe
Kind = KanturuPhaseKind.MonsterWave,
State = KanturuState.MayaBattle,
DetailState = (byte)KanturuMayaDetailState.Monster3,
- TimeLimit = TimeSpan.FromMinutes(10),
+ TimeLimit = TimeSpan.FromMinutes(20),
+ TimeLimitGroup = KanturuWaveGroup.MayaBothHands,
SpawnWaveNumber = 5,
KillTarget = 20,
CountedMonsters = Monsters(DreadfearNumber, TwinTaleNumber),
@@ -260,6 +283,7 @@ IList Monsters(params short[] monsterNumbers) => monsterNumbe
Kind = KanturuPhaseKind.MonsterWave,
State = KanturuState.MayaBattle,
DetailState = (byte)KanturuMayaDetailState.Maya3,
+ TimeLimitGroup = KanturuWaveGroup.MayaBothHands,
SpawnWaveNumber = 6,
KillTarget = 2,
CountedMonsters = Monsters(MayaLeftHandNumber, MayaRightHandNumber),
@@ -289,13 +313,15 @@ IList Monsters(params short[] monsterNumbers) => monsterNumbe
Kind = KanturuPhaseKind.MonsterWave,
State = KanturuState.NightmareBattle,
DetailState = (byte)KanturuNightmareDetailState.Idle,
- TimeLimit = TimeSpan.FromMinutes(30),
+ TimeLimit = TimeSpan.FromMinutes(20),
+ TimeLimitGroup = KanturuWaveGroup.Nightmare,
SpawnWaveNumber = 7,
KillTarget = 45,
CountedMonsters = Monsters(GenociderNumber, DreadfearNumber, PersonaNumber),
StartMessageKey = nameof(PlayerMessage.KanturuNightmareGuardiansAppeared),
// The guardians don't have to be killed; they fight alongside the boss.
+ // The wave clock starts here, so the Nightmare inherits the remainder.
Duration = TimeSpan.FromSeconds(3),
},
new KanturuPhaseDefinition
@@ -304,6 +330,7 @@ IList Monsters(params short[] monsterNumbers) => monsterNumbe
Kind = KanturuPhaseKind.Nightmare,
State = KanturuState.NightmareBattle,
DetailState = (byte)KanturuNightmareDetailState.NightmareIntro,
+ TimeLimitGroup = KanturuWaveGroup.Nightmare,
StartDelay = TimeSpan.FromSeconds(3),
SpawnWaveNumber = 8,
KillTarget = 1,
@@ -315,27 +342,32 @@ IList Monsters(params short[] monsterNumbers) => monsterNumbe
BattleDetailState = (byte)KanturuNightmareDetailState.Battle,
// The boss spawns at (78, 143) and moves within the zone of X:75-88, Y:97-143.
+ // The targets drift towards the Refinery Tower gate as its health lessens.
+ // Each threshold spawns its summon wave around the new position.
HpPhases =
[
new KanturuNightmareHpPhase
{
HealthPercentage = 75,
- TeleportTargetX = 82,
- TeleportTargetY = 130,
+ TeleportTargetX = 79,
+ TeleportTargetY = 100,
+ SummonWaveNumber = 9,
MessageKey = nameof(PlayerMessage.KanturuNightmareTeleport2),
},
new KanturuNightmareHpPhase
{
HealthPercentage = 50,
- TeleportTargetX = 76,
- TeleportTargetY = 115,
+ TeleportTargetX = 78,
+ TeleportTargetY = 124,
+ SummonWaveNumber = 10,
MessageKey = nameof(PlayerMessage.KanturuNightmareTeleport3),
},
new KanturuNightmareHpPhase
{
HealthPercentage = 25,
- TeleportTargetX = 85,
- TeleportTargetY = 100,
+ TeleportTargetX = 78,
+ TeleportTargetY = 141,
+ SummonWaveNumber = 11,
MessageKey = nameof(PlayerMessage.KanturuNightmareTeleport4),
},
],
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuKillResult.cs b/src/GameLogic/MiniGames/Kanturu/KanturuKillResult.cs
new file mode 100644
index 000000000..1e1018863
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuKillResult.cs
@@ -0,0 +1,16 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+///
+/// The outcome of registering a monster kill.
+///
+/// Whether the kill counted towards the current phase.
+/// The kill count after registration.
+/// Whether the kill target has been reached.
+/// Whether the Nightmare boss itself died.
+/// Whether the current phase is a Nightmare phase.
+/// The phase the kill was counted for, if any.
+internal readonly record struct KanturuKillResult(bool Counted, int KillCount, bool PhaseComplete, bool NightmareBossKilled, bool IsNightmarePhase, KanturuPhaseDefinition? Phase);
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuKillTracker.cs b/src/GameLogic/MiniGames/Kanturu/KanturuKillTracker.cs
new file mode 100644
index 000000000..757532bb3
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuKillTracker.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.MiniGames.Kanturu;
+
+using System.Threading;
+using MUnique.OpenMU.DataModel.Configuration;
+
+///
+/// Thread-safe kill counting for the current Kanturu phase.
+///
+///
+/// All mutable per-phase state (phase, kill count, completion source) travels in one
+/// atomically swapped generation: a kill landing concurrently
+/// with counts towards the generation it observed, and can
+/// neither pollute nor complete the next one.
+///
+internal sealed class KanturuKillTracker
+{
+ private PhaseState? _state;
+
+ ///
+ /// Gets the current phase, if any.
+ ///
+ public KanturuPhaseDefinition? CurrentPhase => Volatile.Read(ref this._state)?.Phase;
+
+ ///
+ /// Gets the kill count of the current phase.
+ ///
+ public int KillCount => Volatile.Read(ref this._state)?.KillCount ?? 0;
+
+ ///
+ /// Gets a task which completes when the kill target is reached.
+ ///
+ public Task PhaseCompleted => Volatile.Read(ref this._state)?.Completion.Task ?? Task.CompletedTask;
+
+ ///
+ /// Starts tracking a new phase.
+ ///
+ /// The phase to track.
+ public void BeginPhase(KanturuPhaseDefinition phase)
+ {
+ Volatile.Write(ref this._state, new PhaseState(phase));
+ }
+
+ ///
+ /// Clears the current phase, e.g. for transition phases which count no kills.
+ ///
+ public void ClearPhase()
+ {
+ Volatile.Write(ref this._state, null);
+ }
+
+ ///
+ /// Registers a monster kill.
+ ///
+ /// The definition of the killed monster.
+ /// The outcome, carrying the phase it was counted for.
+ public KanturuKillResult RegisterKill(MonsterDefinition? killed)
+ {
+ var state = Volatile.Read(ref this._state);
+ var phase = state?.Phase;
+ var isNightmarePhase = phase?.Kind == KanturuPhaseKind.Nightmare;
+ if (phase is null || state is null || !KanturuMonsterComparer.IsCountedMonster(killed, phase))
+ {
+ return new KanturuKillResult(false, this.KillCount, false, false, isNightmarePhase, phase);
+ }
+
+ var killCount = state.RegisterKill();
+ var phaseComplete = killCount >= phase.KillTarget;
+ if (phaseComplete)
+ {
+ state.Completion.TrySetResult();
+ }
+
+ var bossKilled = isNightmarePhase
+ && KanturuMonsterComparer.IsSameMonster(phase.Nightmare?.Monster, killed);
+
+ return new KanturuKillResult(true, killCount, phaseComplete, bossKilled, isNightmarePhase, phase);
+ }
+
+ ///
+ /// One generation of kill counting. Swapped atomically, never mutated in place
+ /// except for its own counter.
+ ///
+ private sealed class PhaseState
+ {
+ private int _killCount;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The phase to track.
+ public PhaseState(KanturuPhaseDefinition? phase)
+ {
+ this.Phase = phase;
+ this.Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ }
+
+ ///
+ /// Gets the tracked phase.
+ ///
+ public KanturuPhaseDefinition? Phase { get; }
+
+ ///
+ /// Gets the completion source of the kill target.
+ ///
+ public TaskCompletionSource Completion { get; }
+
+ ///
+ /// Gets the kill count.
+ ///
+ public int KillCount => this._killCount;
+
+ ///
+ /// Registers a kill on this generation.
+ ///
+ /// The kill count after registration.
+ public int RegisterKill() => Interlocked.Increment(ref this._killCount);
+ }
+}
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuMayaWideAttacker.cs b/src/GameLogic/MiniGames/Kanturu/KanturuMayaWideAttacker.cs
new file mode 100644
index 000000000..f1c944b71
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuMayaWideAttacker.cs
@@ -0,0 +1,158 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+using System.Collections.Concurrent;
+using System.Threading;
+using MUnique.OpenMU.GameLogic.NPC;
+
+///
+/// Runs Maya's wide area attack: broadcast, damage, and the pendant kill.
+/// Every player is damaged and those without the Moonstone Pendant die outright.
+///
+internal sealed class KanturuMayaWideAttacker
+{
+ private readonly GameMap _map;
+ private readonly Func, ValueTask> _forEachPlayerAsync;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The map of the event.
+ /// Executes an action for each player.
+ /// The logger.
+ public KanturuMayaWideAttacker(GameMap map, Func, ValueTask> forEachPlayerAsync, ILogger logger)
+ {
+ this._map = map;
+ this._forEachPlayerAsync = forEachPlayerAsync;
+ this._logger = logger;
+ }
+
+ ///
+ /// Runs the attack periodically until cancelled, alternating between storm and
+ /// stone rain rounds.
+ ///
+ /// The interval between two rounds.
+ /// Whether the attack is currently paused, e.g. during standby.
+ /// The cancellation token.
+ public async Task RunAsync(TimeSpan interval, Func isPaused, CancellationToken cancellationToken)
+ {
+ var isStorm = true;
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+
+ if (!isPaused())
+ {
+ await this.ExecuteRoundAsync(isStorm).ConfigureAwait(false);
+ }
+
+ isStorm = !isStorm;
+ }
+ }
+
+ ///
+ /// Executes one round of the attack: alternating storm and stone rain animation,
+ /// damage for every player, death for those without the Moonstone Pendant.
+ ///
+ /// Whether to show the storm instead of the stone rain animation.
+ public async Task ExecuteRoundAsync(bool showStorm)
+ {
+ await this.BroadcastAsync(showStorm).ConfigureAwait(false);
+ await this.ApplyDamageAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Finds the monster which deals the attack: a living Maya monster if there is one,
+ /// otherwise any other living monster of the map.
+ ///
+ /// The monsters to choose from.
+ /// The attacker, or null when no monster is alive.
+ internal static Monster? FindAttacker(IEnumerable monsters)
+ {
+ Monster? fallback = null;
+ foreach (var monster in monsters)
+ {
+ if (!monster.IsAlive)
+ {
+ continue;
+ }
+
+ if (monster.Definition is { } definition && KanturuEventDefinition.MayaMonsterNumbers.Contains(definition.Number))
+ {
+ return monster;
+ }
+
+ fallback ??= monster;
+ }
+
+ return fallback;
+ }
+
+ private ValueTask BroadcastAsync(bool showStorm)
+ {
+ return this._forEachPlayerAsync(player =>
+ player.InvokeViewPlugInAsync(p =>
+ p.ShowMayaWideAreaAttackAsync(showStorm)).AsTask());
+ }
+
+ private async Task ApplyDamageAsync()
+ {
+ var attacker = FindAttacker(this._map.GetAttackablesInRange(KanturuContext.MapCenter, byte.MaxValue).OfType());
+ if (attacker is null)
+ {
+ return;
+ }
+
+ // Players finished below can't be killed inside ForEachPlayerAsync: it holds a
+ // reader lock which the removal from the map would wait for as a writer.
+ var withoutPendant = new ConcurrentBag();
+ var requirements = this._map.Definition.MapRequirements;
+ await this._forEachPlayerAsync(async player =>
+ {
+ if (!player.IsActive() || !player.IsAlive)
+ {
+ return;
+ }
+
+ await player.AttackByAsync(attacker, null, false).ConfigureAwait(false);
+
+ if (player.IsAlive && requirements is { Count: > 0 }
+ && KanturuRequiredItemHelper.GetRequiredItems(player, requirements).Count == 0)
+ {
+ withoutPendant.Add(player);
+ }
+ }).ConfigureAwait(false);
+
+ await this.FinishWithoutPendantAsync(withoutPendant).ConfigureAwait(false);
+ }
+
+ private async Task FinishWithoutPendantAsync(ConcurrentBag players)
+ {
+ foreach (var player in players)
+ {
+ try
+ {
+ if (player.IsAlive)
+ {
+ this._logger.LogInformation("Kanturu: {Player} has no Moonstone Pendant and is killed by Maya's wide area attack.", player);
+ await player.KillInstantlyAsync().ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Unexpected error when killing {Player} without the Moonstone Pendant.", player);
+ }
+ }
+ }
+}
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuMonsterComparer.cs b/src/GameLogic/MiniGames/Kanturu/KanturuMonsterComparer.cs
new file mode 100644
index 000000000..db5f31edf
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuMonsterComparer.cs
@@ -0,0 +1,38 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+using MUnique.OpenMU.DataModel.Configuration;
+
+///
+/// Compares monster definitions by their number, because the configured definition
+/// may be a different instance than the one of the spawned monster.
+///
+internal static class KanturuMonsterComparer
+{
+ ///
+ /// Determines whether the monster definitions describe the same monster.
+ ///
+ /// The first monster definition.
+ /// The second monster definition.
+ /// true if both definitions describe the same monster; otherwise, false.
+ public static bool IsSameMonster(MonsterDefinition? first, MonsterDefinition? second)
+ {
+ return first is not null && second is not null && first.Number == second.Number;
+ }
+
+ ///
+ /// Determines whether the killed monster counts towards the phase kill target.
+ ///
+ /// The definition of the killed monster.
+ /// The current phase.
+ /// true if the kill counts towards the kill target; otherwise, false.
+ public static bool IsCountedMonster(MonsterDefinition? killed, KanturuPhaseDefinition? phase)
+ {
+ return phase is not null
+ && killed is not null
+ && phase.CountedMonsters.Any(counted => IsSameMonster(counted, killed));
+ }
+}
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuMonsterWaveRunner.cs b/src/GameLogic/MiniGames/Kanturu/KanturuMonsterWaveRunner.cs
new file mode 100644
index 000000000..fd9e068a8
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuMonsterWaveRunner.cs
@@ -0,0 +1,54 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+using System.Threading;
+
+///
+/// Runs a monster wave phase: begin, announce, wait for kills, standby.
+///
+internal sealed class KanturuMonsterWaveRunner : IKanturuPhaseRunner
+{
+ private readonly Func _beginAsync;
+ private readonly Func _announceAsync;
+ private readonly Func> _waitAsync;
+ private readonly Func _standbyAsync;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Starts a phase.
+ /// Announces a phase.
+ /// Waits for a phase to end; reports whether it completed.
+ /// Runs the standby time after a phase.
+ public KanturuMonsterWaveRunner(
+ Func beginAsync,
+ Func announceAsync,
+ Func> waitAsync,
+ Func standbyAsync)
+ {
+ this._beginAsync = beginAsync;
+ this._announceAsync = announceAsync;
+ this._waitAsync = waitAsync;
+ this._standbyAsync = standbyAsync;
+ }
+
+ ///
+ public KanturuPhaseKind Kind => KanturuPhaseKind.MonsterWave;
+
+ ///
+ public async Task RunAsync(KanturuPhaseDefinition phase, CancellationToken cancellationToken)
+ {
+ await this._beginAsync(phase, cancellationToken).ConfigureAwait(false);
+ await this._announceAsync(phase).ConfigureAwait(false);
+ if (!await this._waitAsync(phase, cancellationToken).ConfigureAwait(false))
+ {
+ return false;
+ }
+
+ await this._standbyAsync(phase, cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+}
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuNightmareDefinition.cs b/src/GameLogic/MiniGames/Kanturu/KanturuNightmareDefinition.cs
index d3f9272e1..2599a668b 100644
--- a/src/GameLogic/MiniGames/Kanturu/KanturuNightmareDefinition.cs
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuNightmareDefinition.cs
@@ -28,7 +28,7 @@ public class KanturuNightmareDefinition
public TimeSpan HealthCheckInterval { get; set; } = TimeSpan.FromSeconds(1);
///
- /// Gets or sets the delay between restoring the boss' health and teleporting it, so the
+ /// Gets or sets the delay before the boss teleports, so the
/// clients can process the health update first.
///
public TimeSpan TeleportDelay { get; set; } = TimeSpan.FromMilliseconds(500);
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuNightmareHpPhase.cs b/src/GameLogic/MiniGames/Kanturu/KanturuNightmareHpPhase.cs
index 4da2ab5ab..b0b442c3e 100644
--- a/src/GameLogic/MiniGames/Kanturu/KanturuNightmareHpPhase.cs
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuNightmareHpPhase.cs
@@ -6,11 +6,19 @@ namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
///
/// One health based phase of the Nightmare boss fight. When the boss' health drops below
-/// , it's teleported to the configured position and its health
-/// is restored.
+/// , it's teleported to the configured position and the
+/// configured summon wave spawns around the teleport target. Its health is not restored.
///
public class KanturuNightmareHpPhase
{
+ ///
+ /// Gets or sets the number of the spawn wave which is started when this phase starts,
+ /// e.g. 7 Dread Fears around the teleport target. No minions are summoned when it's
+ /// null. It refers to the
+ /// of the spawn areas of the event map.
+ ///
+ public byte? SummonWaveNumber { get; set; }
+
///
/// Gets or sets the health percentage below which this phase starts.
///
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuNightmarePhaseSelector.cs b/src/GameLogic/MiniGames/Kanturu/KanturuNightmarePhaseSelector.cs
new file mode 100644
index 000000000..8cb616626
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuNightmarePhaseSelector.cs
@@ -0,0 +1,43 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+///
+/// Selects the active health phase of the Nightmare boss fight
+/// from its current health percentage.
+///
+internal static class KanturuNightmarePhaseSelector
+{
+ ///
+ /// Gets the target phase index for the given health percentage.
+ ///
+ /// The configured health phases, in order.
+ /// The current health percentage (0-100).
+ /// The index of the phase which should be active (0 = none yet).
+ public static int GetTargetPhaseIndex(IList hpPhases, float healthPercentage)
+ {
+ var targetPhaseIndex = 0;
+ for (var i = 0; i < hpPhases.Count; i++)
+ {
+ if (healthPercentage < hpPhases[i].HealthPercentage)
+ {
+ targetPhaseIndex = i + 1;
+ }
+ }
+
+ return targetPhaseIndex;
+ }
+
+ ///
+ /// Determines whether the monitor should advance to the target phase.
+ ///
+ /// The index of the phase matching the current health.
+ /// The index of the currently active phase.
+ /// true if the monitor should advance; otherwise, false.
+ public static bool ShouldAdvance(int targetPhaseIndex, int currentPhaseIndex)
+ {
+ return targetPhaseIndex > currentPhaseIndex;
+ }
+}
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuNightmareRunner.cs b/src/GameLogic/MiniGames/Kanturu/KanturuNightmareRunner.cs
new file mode 100644
index 000000000..ecf4f5a51
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuNightmareRunner.cs
@@ -0,0 +1,246 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+using System.Threading;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.NPC;
+using MUnique.OpenMU.GameLogic.Views;
+using MUnique.OpenMU.GameLogic.Views.World;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// Runs the Nightmare boss fight: spawn wait, health-phase teleports with minion
+/// summons, special attacks.
+///
+internal sealed class KanturuNightmareRunner : IKanturuPhaseRunner
+{
+ private readonly Func _beginAsync;
+ private readonly Func _showStateAsync;
+ private readonly Func _showGoldenAsync;
+ private readonly Func _showLiveCountAsync;
+ private readonly Func> _waitAsync;
+ private readonly Func _standbyAsync;
+ private readonly Func> _waitForSpawnAsync;
+ private readonly Func, ValueTask> _forEachPlayerAsync;
+ private readonly Func _spawnWaveAsync;
+ private readonly ILogger _logger;
+
+ private Monster? _nightmareMonster;
+ private int _nightmarePhaseIndex;
+ private int _nightmareTeleporting;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Starts a phase.
+ /// Broadcasts a state change to the clients.
+ /// Shows a golden message, if a message key is configured.
+ /// Broadcasts the remaining minion count.
+ /// Waits for a phase to end; reports whether it completed.
+ /// Runs the standby time after a phase.
+ /// Waits for the Nightmare boss to spawn.
+ /// Executes an action for each player.
+ /// Spawns a configured monster wave on the event map.
+ /// The logger.
+ public KanturuNightmareRunner(
+ Func beginAsync,
+ Func showStateAsync,
+ Func showGoldenAsync,
+ Func showLiveCountAsync,
+ Func> waitAsync,
+ Func standbyAsync,
+ Func> waitForSpawnAsync,
+ Func, ValueTask> forEachPlayerAsync,
+ Func spawnWaveAsync,
+ ILogger logger)
+ {
+ this._beginAsync = beginAsync;
+ this._showStateAsync = showStateAsync;
+ this._showGoldenAsync = showGoldenAsync;
+ this._showLiveCountAsync = showLiveCountAsync;
+ this._waitAsync = waitAsync;
+ this._standbyAsync = standbyAsync;
+ this._waitForSpawnAsync = waitForSpawnAsync;
+ this._forEachPlayerAsync = forEachPlayerAsync;
+ this._spawnWaveAsync = spawnWaveAsync;
+ this._logger = logger;
+ }
+
+ ///
+ public KanturuPhaseKind Kind => KanturuPhaseKind.Nightmare;
+
+ ///
+ public async Task RunAsync(KanturuPhaseDefinition phase, CancellationToken cancellationToken)
+ {
+ var nightmare = phase.Nightmare ?? new KanturuNightmareDefinition();
+ this._nightmarePhaseIndex = 0;
+ this._nightmareMonster = null;
+
+ // Arm the spawn capture before beginning the phase: the boss spawns
+ // synchronously inside BeginPhaseAsync (via GameMap.AddAsync raising
+ // ObjectAdded), so subscribing afterwards would miss it. The waiter
+ // subscribes synchronously up to its first await.
+ var spawnTask = this._waitForSpawnAsync(nightmare, cancellationToken);
+ await this._beginAsync(phase, cancellationToken).ConfigureAwait(false);
+ this._nightmareMonster = await spawnTask.ConfigureAwait(false);
+ if (this._nightmareMonster is null)
+ {
+ this._logger.LogWarning(
+ "Kanturu: the Nightmare monster didn't spawn within {Timeout} - its health phases are disabled.",
+ nightmare.SpawnTimeout);
+ }
+
+ await this._showStateAsync(phase.State, nightmare.BattleDetailState).ConfigureAwait(false);
+ await this._showGoldenAsync(phase.StartMessageKey).ConfigureAwait(false);
+ await this._showLiveCountAsync().ConfigureAwait(false);
+
+ using var bossCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ var healthMonitor = Task.Run(() => this.MonitorNightmareHealthAsync(nightmare, bossCts.Token), bossCts.Token);
+ var specialAttacks = Task.Run(() => this.RunNightmareSpecialAttacksAsync(nightmare, bossCts.Token), bossCts.Token);
+
+ var completed = await this._waitAsync(phase, cancellationToken).ConfigureAwait(false);
+
+ await bossCts.CancelAsync().ConfigureAwait(false);
+
+ // Both background loops end through the cancellation above.
+ try
+ {
+ await healthMonitor.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected when the phase ends.
+ }
+
+ try
+ {
+ await specialAttacks.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected when the phase ends.
+ }
+
+ if (!completed)
+ {
+ return false;
+ }
+
+ await this._standbyAsync(phase, cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+
+ private async Task MonitorNightmareHealthAsync(KanturuNightmareDefinition nightmare, CancellationToken ct)
+ {
+ if (nightmare.HpPhases.Count == 0 || nightmare.HealthCheckInterval <= TimeSpan.Zero)
+ {
+ return;
+ }
+
+ while (!ct.IsCancellationRequested)
+ {
+ await Task.Delay(nightmare.HealthCheckInterval, ct).ConfigureAwait(false);
+
+ if (Volatile.Read(ref this._nightmareTeleporting) != 0)
+ {
+ continue;
+ }
+
+ if (this._nightmareMonster is not { IsAlive: true } monster)
+ {
+ continue;
+ }
+
+ var maximumHealth = monster.Attributes[Stats.MaximumHealth];
+ var healthPercentage = maximumHealth > 0 ? monster.Health * 100f / maximumHealth : 100f;
+
+ var targetPhaseIndex = KanturuNightmarePhaseSelector.GetTargetPhaseIndex(nightmare.HpPhases, healthPercentage);
+ if (KanturuNightmarePhaseSelector.ShouldAdvance(targetPhaseIndex, this._nightmarePhaseIndex))
+ {
+ this._nightmarePhaseIndex = targetPhaseIndex;
+ await this.ExecuteNightmareTeleportAsync(monster, nightmare, nightmare.HpPhases[targetPhaseIndex - 1], ct)
+ .ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async Task ExecuteNightmareTeleportAsync(Monster monster, KanturuNightmareDefinition nightmare, KanturuNightmareHpPhase hpPhase, CancellationToken ct)
+ {
+ if (!monster.IsAlive)
+ {
+ return;
+ }
+
+ Interlocked.Exchange(ref this._nightmareTeleporting, 1);
+ try
+ {
+ await Task.Delay(nightmare.TeleportDelay, ct).ConfigureAwait(false);
+ ct.ThrowIfCancellationRequested();
+
+ // The boss may have died while teleporting; moving or announcing it then
+ // would act on a corpse after the death event already ran.
+ if (!monster.IsAlive)
+ {
+ return;
+ }
+
+ await monster.MoveAsync(new Point(hpPhase.TeleportTargetX, hpPhase.TeleportTargetY)).ConfigureAwait(false);
+
+ if (!monster.IsAlive)
+ {
+ return;
+ }
+
+ // The summons are ordinary configured waves around the teleport target.
+ if (hpPhase.SummonWaveNumber is { } summonWaveNumber)
+ {
+ await this._spawnWaveAsync(summonWaveNumber, ct).ConfigureAwait(false);
+ await this._showLiveCountAsync().ConfigureAwait(false);
+ }
+
+ await this._showGoldenAsync(hpPhase.MessageKey).ConfigureAwait(false);
+ }
+ finally
+ {
+ Interlocked.Exchange(ref this._nightmareTeleporting, 0);
+ }
+ }
+
+ private async Task RunNightmareSpecialAttacksAsync(KanturuNightmareDefinition nightmare, CancellationToken ct)
+ {
+ if (nightmare.SpecialAttackInterval <= TimeSpan.Zero)
+ {
+ return;
+ }
+
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(nightmare.SpecialAttackInterval, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+
+ if (this._nightmareMonster is not { IsAlive: true } monster)
+ {
+ break;
+ }
+
+ if (Volatile.Read(ref this._nightmareTeleporting) != 0)
+ {
+ continue;
+ }
+
+ await this._forEachPlayerAsync(player =>
+ player.InvokeViewPlugInAsync(p =>
+ p.ShowSkillAnimationAsync(monster, null, nightmare.SpecialAttackSkillNumber, true)).AsTask())
+ .ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuPhaseDefinition.cs b/src/GameLogic/MiniGames/Kanturu/KanturuPhaseDefinition.cs
index 967946dc2..7011c69e2 100644
--- a/src/GameLogic/MiniGames/Kanturu/KanturuPhaseDefinition.cs
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuPhaseDefinition.cs
@@ -35,8 +35,21 @@ public class KanturuPhaseDefinition
///
/// Gets or sets the time limit which is shown in the client HUD when the phase starts.
///
+ ///
+ /// When is set, only the first phase of the group needs a
+ /// limit: it starts the shared wave clock, and the following phases of the group inherit
+ /// the remaining time instead of getting a fresh timer.
+ ///
public TimeSpan? TimeLimit { get; set; }
+ ///
+ /// Gets or sets the wave whose shared countdown this phase belongs to, if any.
+ /// All phases of one wave (e.g. its monsters and its boss) share a single clock:
+ /// the first phase of the wave which carries a starts it, and
+ /// every later phase of the wave must finish before it expires.
+ ///
+ public KanturuWaveGroup? TimeLimitGroup { get; set; }
+
///
/// Gets or sets the number of the spawn wave which is started with the phase. It refers to
/// the of
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuRequiredItemHelper.cs b/src/GameLogic/MiniGames/Kanturu/KanturuRequiredItemHelper.cs
new file mode 100644
index 000000000..34ce76697
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuRequiredItemHelper.cs
@@ -0,0 +1,44 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.DataModel.Entities;
+
+///
+/// Filters equipped items down to the ones which satisfy event map requirements.
+///
+internal static class KanturuRequiredItemHelper
+{
+ ///
+ /// Gets the equipped items which provide one of the required attributes.
+ ///
+ /// The equipped items to filter.
+ /// The requirements of the event map.
+ /// The equipped items which satisfy one of the requirements.
+ public static IList
- GetRequiredItems(IEnumerable
- ? equippedItems, ICollection requirements)
+ {
+ if (equippedItems is null)
+ {
+ return [];
+ }
+
+ return equippedItems
+ .Where(item => item.Definition?.BasePowerUpAttributes
+ .Any(powerUp => requirements.Any(requirement => requirement.Attribute == powerUp.TargetAttribute)) is true)
+ .ToList();
+ }
+
+ ///
+ /// Gets the equipped items of the player which provide one of the required attributes.
+ ///
+ /// The player whose equipped items are searched.
+ /// The requirements of the event map.
+ /// The equipped items which satisfy one of the requirements.
+ public static IList
- GetRequiredItems(Player player, ICollection requirements)
+ {
+ return GetRequiredItems(player.Inventory?.EquippedItems, requirements);
+ }
+}
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuTowerEntry.cs b/src/GameLogic/MiniGames/Kanturu/KanturuTowerEntry.cs
new file mode 100644
index 000000000..a1f652828
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuTowerEntry.cs
@@ -0,0 +1,173 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// Tower entry without a running event game, e.g. after a server restart while the
+/// Tower of Refinement window is still open.
+///
+public static class KanturuTowerEntry
+{
+ ///
+ /// Gets the remaining tower window, unless a running event game owns entry itself.
+ /// Games which already ended are ignored: they are tearing down and must not block
+ /// the tower.
+ ///
+ /// The player which tries to enter.
+ /// The mini game definition.
+ /// The remaining open window, or null when tower entry doesn't apply.
+ public static TimeSpan? GetRemainingTowerWindow(Player player, MiniGameDefinition definition)
+ {
+ if (KanturuTowerWindow.GetOpenUntilUtc(player.GameContext) is not { } until || until <= DateTime.UtcNow)
+ {
+ return null;
+ }
+
+ if (GetLiveGame(player, definition) is KanturuContext { TowerMode: false })
+ {
+ return null;
+ }
+
+ return until - DateTime.UtcNow;
+ }
+
+ ///
+ /// Makes sure a tower game exists for the player to enter: a running one is reused,
+ /// a stale empty one is replaced, otherwise a new one is created without the event phases.
+ ///
+ /// The player which tries to enter.
+ /// The mini game definition.
+ /// true when tower entry applies; otherwise, false.
+ public static async ValueTask EnsureTowerGameAsync(Player player, MiniGameDefinition definition)
+ {
+ try
+ {
+ return await EnsureTowerGameCoreAsync(player, definition).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // Never break entering: without a tower game the generic entry below
+ // answers with a clean rejection instead.
+ player.Logger.LogError(ex, "Failed to ensure the Kanturu tower game.");
+ return false;
+ }
+ }
+
+ ///
+ /// Creates the transient definition for a tower-only game. It keys identically to
+ /// the event definition, but its timers host the tower for the remaining window
+ /// instead of running the event. It's never persisted.
+ ///
+ /// The mini game definition.
+ /// The remaining tower window.
+ /// The tower game definition.
+ public static MiniGameDefinition CreateTowerDefinition(MiniGameDefinition source, TimeSpan remainingWindow)
+ {
+ return new TowerMiniGameDefinition(source, remainingWindow);
+ }
+
+ ///
+ /// Gets where tower entrants arrive: the Nightmare zone entry, where the transition
+ /// from the Maya fight leads. It's null when no transition is configured.
+ ///
+ /// The event definition.
+ /// The tower entry point, if configured.
+ internal static Point? GetTowerEntryPoint(KanturuEventDefinition definition)
+ {
+ if (definition.Phases.FirstOrDefault(phase => phase.Kind == KanturuPhaseKind.Transition)?.Transition is { } transition)
+ {
+ return new Point(transition.EntryPointX, transition.EntryPointY);
+ }
+
+ return null;
+ }
+
+ private static async ValueTask EnsureTowerGameCoreAsync(Player player, MiniGameDefinition definition)
+ {
+ if (GetRemainingTowerWindow(player, definition) is not { } remaining)
+ {
+ return false;
+ }
+
+ if (player.GameContext.MiniGames.TryGetRunningMiniGame(definition, null) is KanturuContext tower
+ && !tower.IsDisposed && !tower.IsDisposing)
+ {
+ if (IsLiveEventBlocking(tower))
+ {
+ return false;
+ }
+
+ // A running tower is reused, including its short-lived lobby: with instant
+ // start it only exists for milliseconds, so entering must not destroy it.
+ if (IsReusableTower(tower))
+ {
+ return true;
+ }
+
+ // Anything else found here is tearing down (ended with stragglers, disposing),
+ // dispose it so the creation below starts fresh instead of reusing a dead map.
+ await tower.DisposeAsync().ConfigureAwait(false);
+ }
+
+ var game = await player.GameContext.MiniGames.GetOrCreateAsync(CreateTowerDefinition(definition, remaining), player).ConfigureAwait(false);
+ return game is KanturuContext;
+ }
+
+ private static bool IsLiveEventBlocking(KanturuContext tower)
+ {
+ // A live event owns entry; never break it for the tower.
+ return !tower.TowerMode && tower.State is not (MiniGameState.Ended or MiniGameState.Disposed);
+ }
+
+ private static bool IsReusableTower(KanturuContext tower)
+ {
+ // Usability is defined by state, not by lingering players: an ended game
+ // with stragglers still inside is already over.
+ return tower.TowerMode && tower.State is MiniGameState.Open or MiniGameState.Closed or MiniGameState.Playing;
+ }
+
+ private static MiniGameContext? GetLiveGame(Player player, MiniGameDefinition definition)
+ {
+ var game = player.GameContext.MiniGames.TryGetRunningMiniGame(definition, null);
+ return game is { IsDisposed: false, IsDisposing: false }
+ && game.State is not (MiniGameState.Ended or MiniGameState.Disposed)
+ ? game
+ : null;
+ }
+
+ ///
+ /// A mini game definition which carries the tower timers. The collections stay
+ /// empty: a tower game spawns no waves, grants no rewards and runs no change events.
+ ///
+ private sealed class TowerMiniGameDefinition : MiniGameDefinition
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The mini game definition to take the key and capacity from.
+ /// The remaining tower window.
+ public TowerMiniGameDefinition(MiniGameDefinition source, TimeSpan remainingWindow)
+ {
+ this.Type = source.Type;
+ this.Name = source.Name;
+ this.Description = source.Description;
+ this.GameLevel = source.GameLevel;
+ this.MapCreationPolicy = source.MapCreationPolicy;
+ this.Entrance = source.Entrance;
+ this.MaximumPlayerCount = source.MaximumPlayerCount;
+ this.AllowParty = source.AllowParty;
+ this.SaveRankingStatistics = false;
+ this.EnterDuration = TimeSpan.Zero;
+ this.GameDuration = remainingWindow;
+ this.ExitDuration = TimeSpan.Zero;
+ this.Rewards = new List();
+ this.SpawnWaves = new List();
+ this.ChangeEvents = new List();
+ }
+ }
+}
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuTowerWindow.cs b/src/GameLogic/MiniGames/Kanturu/KanturuTowerWindow.cs
new file mode 100644
index 000000000..1a00b54a2
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuTowerWindow.cs
@@ -0,0 +1,97 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
+using MUnique.OpenMU.Persistence;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Reads and writes the Tower of Refinement open window. The window is stored in the
+/// Kanturu start plug-in configuration, so it survives server restarts: the persisted
+/// JSON is loaded back at startup. Reads use the live configuration, so they also work
+/// where no asynchronous call is possible, such as the scheduler start gate.
+///
+internal static class KanturuTowerWindow
+{
+ ///
+ /// Gets the UTC time until which the tower is open, if a window is currently stored.
+ ///
+ /// The game context.
+ /// The stored end of the open window, if any.
+ public static DateTime? GetOpenUntilUtc(IGameContext gameContext)
+ {
+ return GetConfiguration(gameContext)?.TowerOpenUntilUtc;
+ }
+
+ ///
+ /// Stores the end of the open window, both live and persisted. A persistence failure
+ /// only affects restart survival; the running game is unaffected.
+ ///
+ /// The game context.
+ /// The UTC time until which the tower is open, or null to clear the window.
+ /// The logger.
+ public static async ValueTask SetOpenUntilUtcAsync(IGameContext gameContext, DateTime? untilUtc, ILogger logger)
+ {
+ try
+ {
+ var live = GetConfiguration(gameContext);
+ if (live is null)
+ {
+ logger.LogWarning("The Kanturu start plugin configuration is not available to store the tower window.");
+ return;
+ }
+
+ live.TowerOpenUntilUtc = untilUtc;
+
+ using var context = gameContext.PersistenceContextProvider.CreateNewContext();
+ var entity = await FindConfigurationEntityAsync(context).ConfigureAwait(false);
+ if (entity is null)
+ {
+ logger.LogWarning("Could not find the Kanturu start plugin configuration row to persist the tower window.");
+ return;
+ }
+
+ // Read-modify-write the persisted row instead of serializing the live
+ // object: an admin saving the event schedule concurrently must not lose
+ // the tower window, and vice versa.
+ var persisted = entity.GetConfiguration(gameContext.PlugInManager.CustomConfigReferenceHandler)
+ ?? live;
+ persisted.TowerOpenUntilUtc = untilUtc;
+ entity.SetConfiguration(persisted, gameContext.PlugInManager.CustomConfigReferenceHandler);
+ await context.SaveChangesAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to persist the Kanturu tower window.");
+ }
+ }
+
+ private static KanturuStartConfiguration? GetConfiguration(IGameContext gameContext)
+ {
+ try
+ {
+ var startPlugIn = gameContext.PlugInManager
+ .GetStrategy(MiniGameType.Kanturu);
+ if (startPlugIn is ISupportCustomConfiguration { Configuration: { } configuration })
+ {
+ return configuration;
+ }
+ }
+ catch (Exception ex)
+ {
+ gameContext.LoggerFactory.CreateLogger(typeof(KanturuTowerWindow)).LogError(ex, "Failed to read the Kanturu tower window.");
+ }
+
+ return null;
+ }
+
+ private static async ValueTask FindConfigurationEntityAsync(IContext context)
+ {
+ var typeId = typeof(KanturuStartPlugIn).GUID;
+ var gameConfiguration = (await context.GetAsync().ConfigureAwait(false)).FirstOrDefault();
+ return gameConfiguration?.PlugInConfigurations.FirstOrDefault(c => c.TypeId == typeId);
+ }
+}
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuTransitionRunner.cs b/src/GameLogic/MiniGames/Kanturu/KanturuTransitionRunner.cs
new file mode 100644
index 000000000..c8049da68
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuTransitionRunner.cs
@@ -0,0 +1,66 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+using System.Threading;
+using MUnique.OpenMU.GameLogic.Views.World;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// Runs the transition into the Nightmare zone: cinematic, move, warp animation.
+///
+///
+/// The detail state triggers the full client cinematic; players are moved only after
+/// that, so the movement isn't visible during the animation.
+///
+internal sealed class KanturuTransitionRunner : IKanturuPhaseRunner
+{
+ private readonly Func _showStateAsync;
+ private readonly Func, ValueTask> _forEachPlayerAsync;
+ private readonly Action _clearPhase;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Broadcasts a state change to the clients.
+ /// Executes an action for each player.
+ /// Clears the current phase of the kill tracker.
+ public KanturuTransitionRunner(
+ Func showStateAsync,
+ Func, ValueTask> forEachPlayerAsync,
+ Action clearPhase)
+ {
+ this._showStateAsync = showStateAsync;
+ this._forEachPlayerAsync = forEachPlayerAsync;
+ this._clearPhase = clearPhase;
+ }
+
+ ///
+ public KanturuPhaseKind Kind => KanturuPhaseKind.Transition;
+
+ ///
+ public async Task RunAsync(KanturuPhaseDefinition phase, CancellationToken cancellationToken)
+ {
+ var transition = phase.Transition ?? new KanturuTransitionDefinition();
+ this._clearPhase();
+
+ await this._showStateAsync(phase.State, phase.DetailState).ConfigureAwait(false);
+
+ // The cinematic is never skipped in the middle (a game master skip waits for it),
+ // but it still observes the game end, so a torn-down game doesn't linger in it.
+ await Task.Delay(transition.CinematicDuration, cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var entryPoint = new Point(transition.EntryPointX, transition.EntryPointY);
+ await this._forEachPlayerAsync(player => player.MoveAsync(entryPoint).AsTask()).ConfigureAwait(false);
+
+ await Task.Delay(transition.WarpAnimationDelay, cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+ await this._forEachPlayerAsync(player =>
+ player.InvokeViewPlugInAsync(p =>
+ p.MapChangeFailedAsync()).AsTask()).ConfigureAwait(false);
+ return true;
+ }
+}
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuWaveGroup.cs b/src/GameLogic/MiniGames/Kanturu/KanturuWaveGroup.cs
new file mode 100644
index 000000000..1d68b0767
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuWaveGroup.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.MiniGames.Kanturu;
+
+///
+/// One wave of the Kanturu event. All phases of a wave share a single countdown
+/// (see ): the monsters and the
+/// boss of the wave must be killed before it expires.
+///
+public enum KanturuWaveGroup
+{
+ ///
+ /// The first wave: monsters, then Maya's left hand.
+ ///
+ MayaLeftHand,
+
+ ///
+ /// The second wave: monsters, then Maya's right hand.
+ ///
+ MayaRightHand,
+
+ ///
+ /// The third wave: monsters, then both hands of Maya.
+ ///
+ MayaBothHands,
+
+ ///
+ /// The fourth wave: the guardians, then Nightmare.
+ ///
+ Nightmare,
+}
diff --git a/src/GameLogic/MiniGames/Kanturu/KanturuWaveTimer.cs b/src/GameLogic/MiniGames/Kanturu/KanturuWaveTimer.cs
new file mode 100644
index 000000000..8de595f39
--- /dev/null
+++ b/src/GameLogic/MiniGames/Kanturu/KanturuWaveTimer.cs
@@ -0,0 +1,58 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+///
+/// Tracks the shared countdown of a Kanturu wave. All phases of one wave (e.g. its
+/// monsters and its boss) share a single clock: the first phase of the group which
+/// carries a starts it, and every later
+/// phase of the group inherits the remaining time instead of getting a fresh timer.
+///
+///
+/// Only the game loop thread calls this; no locking is needed.
+///
+internal sealed class KanturuWaveTimer
+{
+ private readonly Dictionary _deadlines = new();
+ private readonly Func _utcNow;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The clock, for tests. Defaults to .
+ public KanturuWaveTimer(Func? utcNow = null)
+ {
+ this._utcNow = utcNow ?? (() => DateTime.UtcNow);
+ }
+
+ ///
+ /// Gets the effective time limit of the phase: its own ,
+ /// or the remaining shared time when it follows a wave clock. The first phase of the wave which
+ /// carries a limit starts the clock; a later phase of the wave without a recorded clock falls back
+ /// to its own limit (usually null, meaning no limit).
+ ///
+ /// The phase which is about to run.
+ /// The effective limit, or null when the phase has no countdown.
+ public TimeSpan? GetEffectiveLimit(KanturuPhaseDefinition phase)
+ {
+ if (phase.TimeLimitGroup is not { } group)
+ {
+ return phase.TimeLimit;
+ }
+
+ if (this._deadlines.TryGetValue(group, out var deadline))
+ {
+ return deadline - this._utcNow();
+ }
+
+ if (phase.TimeLimit is not { } limit)
+ {
+ return null;
+ }
+
+ this._deadlines[group] = this._utcNow() + limit;
+ return limit;
+ }
+}
diff --git a/src/GameLogic/MiniGames/MiniGameContext.cs b/src/GameLogic/MiniGames/MiniGameContext.cs
index 8676c30fa..97a3e1153 100644
--- a/src/GameLogic/MiniGames/MiniGameContext.cs
+++ b/src/GameLogic/MiniGames/MiniGameContext.cs
@@ -11,6 +11,7 @@ namespace MUnique.OpenMU.GameLogic.MiniGames;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Pathfinding;
///
/// The context of a mini game.
@@ -34,6 +35,8 @@ public class MiniGameContext : AsyncDisposable, IEventStateProvider
private readonly SkippableDelay _skipDelay;
+ private int _gameLoopStarted;
+
private Stopwatch? _elapsedTimeSinceStart;
///
@@ -57,7 +60,6 @@ public MiniGameContext(MiniGameMapKey key, MiniGameDefinition definition, IGameC
this.Map = this.CreateMap();
this._players = new MiniGamePlayerRegistry(this.Definition);
-
// Rewards intentionally follow the game's (possibly overridden) drop generator
// instead of the game context one: ChaosCastleDropGenerator only overrides monster
// kill drops and delegates reward generation back to the context generator,
@@ -83,8 +85,6 @@ public MiniGameContext(MiniGameMapKey key, MiniGameDefinition definition, IGameC
this,
message => this.ShowGoldenMessageAsync(message),
() => this._elapsedTimeSinceStart?.Elapsed);
-
- _ = Task.Run(() => this.RunGameAsync(this.GameEndedToken), this.GameEndedToken);
}
///
@@ -129,6 +129,38 @@ public MiniGameContext(MiniGameMapKey key, MiniGameDefinition definition, IGameC
///
public virtual bool AllowPlayerKilling { get; }
+ ///
+ /// Gets a value indicating whether players may still enter while the game is
+ /// already running (), e.g. to rejoin an
+ /// ongoing event. It's false by default; entering is then only possible
+ /// while the game is .
+ ///
+ internal bool IsJoinable => this.State == MiniGameState.Open
+ || (this.State == MiniGameState.Playing && this.AllowEnterWhilePlaying);
+
+ ///
+ /// Gets a value indicating whether the map entry requirements are skipped when
+ /// entering, e.g. an event item which the tower visitors no longer need.
+ ///
+ internal virtual bool SkipMapEntryRequirements => false;
+
+ ///
+ /// Gets a value indicating whether entering is allowed while the game is already
+ /// running. Specific games override this to let players (re-)join mid-event.
+ ///
+ protected virtual bool AllowEnterWhilePlaying => false;
+
+ ///
+ /// Gets the duration of the countdown after the entrance closed and before the game starts.
+ ///
+ protected virtual TimeSpan CountdownDuration => CountdownMessageDuration;
+
+ ///
+ /// Gets the minimum duration of the entrance phase. Games which don't need a lobby,
+ /// e.g. a reopened tower, override this with .
+ ///
+ protected virtual TimeSpan MinimumEnterDuration => CountdownMessageDuration;
+
///
/// Gets the remaining time of the event, in case it has been finished by the player earlier than the timeout.
///
@@ -178,7 +210,7 @@ public MiniGameContext(MiniGameMapKey key, MiniGameDefinition definition, IGameC
/// A value indicating whether entering had success.
public async ValueTask TryEnterAsync(Player player)
{
- var result = await this._players.TryEnterAsync(player, this.AreEquippedItemsAllowedAsync).ConfigureAwait(false);
+ var result = await this._players.TryEnterAsync(player, this.AreEquippedItemsAllowedAsync, () => this.AllowEnterWhilePlaying).ConfigureAwait(false);
if (result != EnterResult.Success)
{
return result;
@@ -250,6 +282,28 @@ public override string ToString()
return $"{this.Definition.Name} for {this._gameContext}";
}
+ ///
+ /// Gets where an entering player appears on the map, instead of the warp target.
+ /// It's consulted when the client acknowledged the map change, before the player
+ /// is added to the map, so no relocation afterwards is necessary.
+ ///
+ /// The player which enters.
+ /// The spawn position, or null to keep the warp target.
+ internal virtual Point? GetEntrySpawnPosition(Player player) => null;
+
+ ///
+ /// Starts the game loop, unless it was started before. It can't start in the
+ /// constructor: the loop reads overridden members which aren't ready until the
+ /// derived constructor body ran.
+ ///
+ internal void EnsureGameLoopRunning()
+ {
+ if (Interlocked.CompareExchange(ref this._gameLoopStarted, 1, 0) == 0)
+ {
+ _ = Task.Run(() => this.RunGameAsync(this.GameEndedToken), this.GameEndedToken);
+ }
+ }
+
///
/// Waits for the specified duration, unless the wait is skipped through
/// or the is cancelled.
@@ -557,7 +611,7 @@ private async ValueTask RunGameAsync(CancellationToken cancellationToken)
this.Logger.LogDebug("{context}: Running the game ...", this);
try
{
- var enterDuration = this.Definition.EnterDuration.AtLeast(CountdownMessageDuration);
+ var enterDuration = this.Definition.EnterDuration.AtLeast(this.MinimumEnterDuration);
var gameDuration = this.Definition.GameDuration.AtLeast(CountdownMessageDuration);
var exitDuration = this.Definition.ExitDuration.Subtract(CountdownMessageDuration).AtLeast(CountdownMessageDuration);
@@ -597,8 +651,8 @@ private async ValueTask RunGameAsync(CancellationToken cancellationToken)
}
await this.ShowCountdownMessageAsync().ConfigureAwait(false);
- this.Logger.LogDebug("{context}: Waiting for the countdown duration of {countdownDuration}", this, CountdownMessageDuration);
- await this.DelayWithSkipAsync(CountdownMessageDuration, cancellationToken).ConfigureAwait(false);
+ this.Logger.LogDebug("{context}: Waiting for the countdown duration of {countdownDuration}", this, this.CountdownDuration);
+ await this.DelayWithSkipAsync(this.CountdownDuration, cancellationToken).ConfigureAwait(false);
this.Logger.LogDebug("{context}: Starting the game...", this);
await this.StartAsync().ConfigureAwait(false);
@@ -663,7 +717,17 @@ private async ValueTask ShowCountdownMessageAsync()
private async ValueTask StopAsync()
{
await this._players.SetStateAsync(MiniGameState.Ended).ConfigureAwait(false);
- await this._gameEndedCts.CancelAsync().ConfigureAwait(false);
+ try
+ {
+ await this._gameEndedCts.CancelAsync().ConfigureAwait(false);
+ }
+ catch (ObjectDisposedException)
+ {
+ // Already torn down, e.g. by a repeated game-master restart racing this
+ // loop: disposal already warped the players out, so there is nothing to stop.
+ this.Logger.LogDebug("{context}: StopAsync called on a disposed game, skipping.", this);
+ return;
+ }
this._spawnWaves.Clear();
await this.Map.ClearEventSpawnedNpcsAsync().ConfigureAwait(false);
diff --git a/src/GameLogic/MiniGames/MiniGameManager.cs b/src/GameLogic/MiniGames/MiniGameManager.cs
index 75e7a778e..911bc8ea9 100644
--- a/src/GameLogic/MiniGames/MiniGameManager.cs
+++ b/src/GameLogic/MiniGames/MiniGameManager.cs
@@ -101,6 +101,10 @@ public async ValueTask GetOrCreateAsync(MiniGameDefinition mini
await this._mapInitializer.InitializeStateAsync(createdMap).ConfigureAwait(false);
this.GameMapCreated?.Invoke(this, createdMap);
MiniGameCounter.Add(1);
+
+ // The loop starts here and not in the constructor, so overridden members
+ // read their post-construction values from the first tick on.
+ miniGameContext.EnsureGameLoopRunning();
return miniGameContext;
}
diff --git a/src/GameLogic/MiniGames/MiniGamePlayerRegistry.cs b/src/GameLogic/MiniGames/MiniGamePlayerRegistry.cs
index 514cca040..12c97d108 100644
--- a/src/GameLogic/MiniGames/MiniGamePlayerRegistry.cs
+++ b/src/GameLogic/MiniGames/MiniGamePlayerRegistry.cs
@@ -58,12 +58,14 @@ public async ValueTask SetStateAsync(MiniGameState state)
///
/// The player which tries to enter.
/// A function which checks if the equipped items of the player are allowed.
+ /// Whether entering is also allowed while the game is already running, e.g. to rejoin an ongoing event. It's evaluated while holding the entering lock and must return immediately, without awaiting or locking.
/// A value indicating whether entering had success.
- public async ValueTask TryEnterAsync(Player player, Func> areEquippedItemsAllowedAsync)
+ public async ValueTask TryEnterAsync(Player player, Func> areEquippedItemsAllowedAsync, Func? allowEnterWhilePlaying = null)
{
using (await this._lock.WriterLockAsync().ConfigureAwait(false))
{
- if (this._state != MiniGameState.Open)
+ if (this._state != MiniGameState.Open
+ && !((allowEnterWhilePlaying?.Invoke() ?? false) && this._state == MiniGameState.Playing))
{
return EnterResult.NotOpen;
}
diff --git a/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs b/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs
index 836da04aa..ed903587e 100644
--- a/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs
+++ b/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs
@@ -61,9 +61,11 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam
}
// The mini game entrance warps the player directly, so the requirements of the map
- // are not checked by the usual warp actions. Kanturu, for example, requires an
- // equipped Moonstone Pendant.
+ // are not checked by the usual warp actions. Some maps require equipped items.
+ // A running game may waive them, e.g. the open tower no longer needs the pendant.
+ var liveGame = player.GameContext.MiniGames.TryGetRunningMiniGame(miniGameDefinition, null);
if (miniGameDefinition.Entrance?.Map is { } entranceMap
+ && liveGame?.SkipMapEntryRequirements is not true
&& entranceMap.TryGetRequirementError(player, out var requirementError))
{
await player.ShowBlueMessageAsync(requirementError).ConfigureAwait(false);
@@ -100,7 +102,8 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam
{
var miniGameStrategy = player.GameContext.PlugInManager.GetStrategy(miniGameDefinition.Type);
if (miniGameStrategy is not null
- && await miniGameStrategy.GetDurationUntilNextStartAsync(player.GameContext, miniGameDefinition).ConfigureAwait(false) != TimeSpan.Zero)
+ && await miniGameStrategy.GetDurationUntilNextStartAsync(player.GameContext, miniGameDefinition).ConfigureAwait(false) != TimeSpan.Zero
+ && !IsJoinableRunningGame(player, miniGameDefinition))
{
await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(miniGameType, EnterResult.NotOpen)).ConfigureAwait(false);
return;
@@ -151,6 +154,18 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam
}
}
+ ///
+ /// Determines whether a running game of the definition exists which players may
+ /// still join, e.g. to rejoin an ongoing event.
+ ///
+ /// The player.
+ /// The mini game definition.
+ /// true if a joinable game is running; otherwise, false.
+ private static bool IsJoinableRunningGame(Player player, MiniGameDefinition miniGameDefinition)
+ {
+ return player.GameContext.MiniGames.TryGetRunningMiniGame(miniGameDefinition, null)?.IsJoinable is true;
+ }
+
private bool CheckPlayerKillState(MiniGameDefinition miniGameDefinition, Player player)
{
if (miniGameDefinition.ArePlayerKillersAllowedToEnter)
diff --git a/src/GameLogic/PlayerMapTransitions.cs b/src/GameLogic/PlayerMapTransitions.cs
index 4b2d1a297..b6c2ff78a 100644
--- a/src/GameLogic/PlayerMapTransitions.cs
+++ b/src/GameLogic/PlayerMapTransitions.cs
@@ -249,6 +249,12 @@ public async ValueTask ClientReadyAfterMapChangeAsync()
await player.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.EnteredWorld).ConfigureAwait(false);
player.IsAlive = true;
+ if (player.CurrentMiniGame?.GetEntrySpawnPosition(player) is { } spawnPosition)
+ {
+ player.SelectedCharacter.PositionX = spawnPosition.X;
+ player.SelectedCharacter.PositionY = spawnPosition.Y;
+ }
+
await player.CurrentMap!.AddAsync(player).ConfigureAwait(false);
if (!player.CurrentMap.Terrain.WalkMap[player.SelectedCharacter.PositionX, player.SelectedCharacter.PositionY]
&& await this.RecoverFromBlockedSpawnAsync().ConfigureAwait(false))
diff --git a/src/GameLogic/PlugIns/ChatCommands/StartMiniGameEventChatCommandPlugInBase.cs b/src/GameLogic/PlugIns/ChatCommands/StartMiniGameEventChatCommandPlugInBase.cs
index faf99a541..7709369ae 100644
--- a/src/GameLogic/PlugIns/ChatCommands/StartMiniGameEventChatCommandPlugInBase.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/StartMiniGameEventChatCommandPlugInBase.cs
@@ -38,7 +38,6 @@ public async ValueTask HandleCommandAsync(Player player, string command)
?? this.MiniGameType.ToString();
if (gameStarter.IsEventActive(player.GameContext))
{
- await gameStarter.DisposeRunningGamesAsync(player.GameContext).ConfigureAwait(false);
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.MiniGameForceRestartFormat), eventName).ConfigureAwait(false);
}
else
@@ -46,6 +45,11 @@ public async ValueTask HandleCommandAsync(Player player, string command)
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.MiniGameForceStartInitiatedFormat), eventName).ConfigureAwait(false);
}
+ // Always dispose first, even without a running game: it's a no-op then, but
+ // clears stale state which would block the forced start (e.g. Kanturu's open
+ // tower window).
+ await gameStarter.DisposeRunningGamesAsync(player.GameContext).ConfigureAwait(false);
+
gameStarter.ForceStart();
}
}
diff --git a/src/GameLogic/PlugIns/KanturuGatewayPlugIn.cs b/src/GameLogic/PlugIns/KanturuGatewayPlugIn.cs
index 31ef752fb..15e79fdab 100644
--- a/src/GameLogic/PlugIns/KanturuGatewayPlugIn.cs
+++ b/src/GameLogic/PlugIns/KanturuGatewayPlugIn.cs
@@ -34,6 +34,10 @@ public class KanturuGatewayPlugIn : IPlayerTalkToNpcPlugIn
// KANTURU_MAYA_DIRECTION_STANBY1 = 1 — shows user count and enables Enter button.
private const byte DetailStandbyOpen = 1;
+ // Detail state for the dialog while the next start is awaited:
+ // STANBY_START = 1 — client shows "Opens in X minutes".
+ private const byte DetailStandbyStart = 1;
+
///
/// Sends the 0xD1/0x00 StateInfo packet to the player so the client opens the
/// gateway dialog. Also called from the KanturuInfoRequestHandlerPlugIn
@@ -63,53 +67,12 @@ public static async ValueTask SendKanturuStateInfoAsync(Player player)
.GetDurationUntilNextStartAsync(player.GameContext, miniGameDefinition)
.ConfigureAwait(false);
- KanturuState state;
- byte detailState;
- bool canEnter;
- int userCount;
- TimeSpan remainTime;
-
- 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: sealed — the Nightmare encounter cannot be joined mid-fight.
- // Tower phase: sealed — survivors are auto-teleported to the Tower when the
- // Elphis barrier opens; players who died are excluded from the Tower for that
- // cycle and cannot re-enter via the Gateway (which would drop them in the Maya
- // room and let them appear to restart the event).
- canEnter = state == KanturuState.MayaBattle;
-
- userCount = ctx.PlayerCount;
- remainTime = 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;
- remainTime = 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;
- remainTime = timeUntilOpening ?? TimeSpan.Zero;
- }
+ var info = GetRunningEventInfo(ctx, player.GameContext)
+ ?? GetOpenTowerInfo(player, miniGameDefinition)
+ ?? GetLobbyOrCountdownInfo(timeUntilOpening);
await player.InvokeViewPlugInAsync(p =>
- p.ShowStateInfoAsync(state, detailState, canEnter, userCount, remainTime))
+ p.ShowStateInfoAsync(info.State, info.DetailState, info.CanEnter, info.UserCount, info.RemainTime))
.ConfigureAwait(false);
}
@@ -130,4 +93,91 @@ public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter n
await SendKanturuStateInfoAsync(player).ConfigureAwait(false);
}
+
+ ///
+ /// Gets the remaining tower window for the state info dialog, so the client can
+ /// show when the tower closes.
+ ///
+ /// The game context.
+ /// The remaining open window, or when closed.
+ private static TimeSpan GetTowerRemainingTime(IGameContext gameContext)
+ {
+ if (KanturuTowerWindow.GetOpenUntilUtc(gameContext) is { } until)
+ {
+ var remaining = until - DateTime.UtcNow;
+ if (remaining > TimeSpan.Zero)
+ {
+ return remaining;
+ }
+ }
+
+ return TimeSpan.Zero;
+ }
+
+ private static KanturuDialogInfo? GetRunningEventInfo(MiniGameContext? ctx, IGameContext gameContext)
+ {
+ if (ctx is not KanturuContext kanturuCtx || kanturuCtx.State is MiniGameState.Ended or MiniGameState.Disposed)
+ {
+ return null;
+ }
+
+ // A Kanturu event is actively running — reflect its real-time phase.
+ var state = kanturuCtx.CurrentKanturuState;
+
+ // Entry allowed before the event starts and while the Tower of Refinement
+ // is open, so that players who died or left can rejoin the tower. Fights
+ // can never be joined mid-event; the Nightmare encounter is sealed.
+ var canEnter = kanturuCtx.IsJoinable && state is KanturuState.MayaBattle or KanturuState.Tower;
+
+ // During a refill standby the map HUD is hidden, but the dialog shows the
+ // standby state, which is what enables its Enter button.
+ var detailState = kanturuCtx.CurrentKanturuDetailState;
+ if (canEnter && state == KanturuState.MayaBattle && detailState == KanturuContext.HudHiddenDetailState)
+ {
+ detailState = DetailStandbyOpen;
+ }
+
+ var remainTime = state == KanturuState.Tower ? GetTowerRemainingTime(gameContext) : TimeSpan.Zero;
+ return new KanturuDialogInfo(state, detailState, canEnter, kanturuCtx.PlayerCount, remainTime);
+ }
+
+ private static KanturuDialogInfo? GetOpenTowerInfo(Player player, MiniGameDefinition miniGameDefinition)
+ {
+ if (KanturuTowerWindow.GetOpenUntilUtc(player.GameContext) is not { } towerUntil || towerUntil <= DateTime.UtcNow)
+ {
+ return null;
+ }
+
+ // No event game runs, but the tower window is still open (e.g. after a
+ // server restart): entering recreates the tower without the event phases.
+ PrewarmTowerGame(player, miniGameDefinition);
+ return new KanturuDialogInfo(
+ KanturuState.Tower,
+ (byte)KanturuTowerDetailState.Revitalization,
+ true,
+ 0,
+ GetTowerRemainingTime(player.GameContext));
+ }
+
+ private static KanturuDialogInfo GetLobbyOrCountdownInfo(TimeSpan? timeUntilOpening)
+ {
+ if (timeUntilOpening == TimeSpan.Zero)
+ {
+ // Entry is open but no game exists yet: entering creates it on demand.
+ return new KanturuDialogInfo(KanturuState.MayaBattle, DetailStandbyOpen, true, 0, TimeSpan.Zero);
+ }
+
+ // No active event — show countdown to the next scheduled start.
+ return new KanturuDialogInfo(KanturuState.Standby, DetailStandbyStart, false, 0, timeUntilOpening ?? TimeSpan.Zero);
+ }
+
+ private static void PrewarmTowerGame(Player player, MiniGameDefinition miniGameDefinition)
+ {
+ // Start the creation while the player reads the dialog, so entering itself
+ // feels like any other map. Failures are logged inside and surface as a
+ // clean rejection on entry.
+ _ = Task.Run(() => KanturuTowerEntry.EnsureTowerGameAsync(player, miniGameDefinition).AsTask());
+ }
+
+ private readonly record struct KanturuDialogInfo(KanturuState State, byte DetailState, bool CanEnter, int UserCount, TimeSpan RemainTime);
}
diff --git a/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartConfiguration.cs b/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartConfiguration.cs
index 48b9ab25c..fde8c918b 100644
--- a/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartConfiguration.cs
+++ b/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartConfiguration.cs
@@ -13,9 +13,10 @@ public class KanturuStartConfiguration : MiniGameStartConfiguration
{
///
/// 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.
+ /// The event runs every 6 hours: each tick opens a silent entry lobby while no
+ /// fight runs and the tower is closed. Entry is additionally always open on demand,
+ /// and the tower stays open for after Nightmare dies.
+ /// There are no entrance announcements; the gateway dialog shows the live state.
///
public static KanturuStartConfiguration Default =>
new()
@@ -23,10 +24,32 @@ public class KanturuStartConfiguration : MiniGameStartConfiguration
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
+ TaskDuration = TimeSpan.FromHours(6),
+ Timetable = GenerateTimeSequence(TimeSpan.FromHours(6)).ToList(),
+ TowerOpenDuration = TimeSpan.FromHours(23),
};
+ ///
+ /// Gets or sets how long the Tower of Refinement stays open after the Nightmare boss
+ /// has been defeated. The window is tracked persistently, so it survives server
+ /// restarts: players can still re-enter the tower while it lasts.
+ ///
+ ///
+ /// This takes precedence over
+ /// whenever the event runs through this plug-in.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.KanturuStartConfiguration_TowerOpenDuration_Name), Description = nameof(PlugInResources.KanturuStartConfiguration_TowerOpenDuration_Description), Order = 6)]
+ public TimeSpan TowerOpenDuration { get; set; } = TimeSpan.FromHours(23);
+
+ ///
+ /// Gets or sets the UTC time until which the Tower of Refinement is open.
+ /// It's set when the Nightmare boss is defeated and cleared when the tower closes
+ /// or a new event run starts. Persisted with the configuration, so the open window
+ /// survives server restarts.
+ ///
+ [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.KanturuStartConfiguration_TowerOpenUntilUtc_Name), Description = nameof(PlugInResources.KanturuStartConfiguration_TowerOpenUntilUtc_Description), Order = 7)]
+ public DateTime? TowerOpenUntilUtc { get; set; }
+
///
/// Gets or sets the definition of the event run itself: its phases, the monsters which
/// have to be killed in each of them, the boss fight and the Tower of Refinement.
diff --git a/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartPlugIn.cs
index 0e8e3e4ad..5c187eb2b 100644
--- a/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartPlugIn.cs
+++ b/src/GameLogic/PlugIns/PeriodicTasks/KanturuStartPlugIn.cs
@@ -6,6 +6,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.MiniGames;
+using MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
using MUnique.OpenMU.PlugIns;
///
@@ -25,9 +26,65 @@ public override object CreateDefaultConfig()
return KanturuStartConfiguration.Default;
}
+ ///
+ public override async ValueTask DisposeRunningGamesAsync(IGameContext gameContext)
+ {
+ // A game master restart supersedes the tower window: clear it so the forced
+ // start below isn't blocked by it. The regular schedule stays blocked.
+ await KanturuTowerWindow.SetOpenUntilUtcAsync(
+ gameContext,
+ null,
+ gameContext.LoggerFactory.CreateLogger(this.GetType())).ConfigureAwait(false);
+
+ // A forced start must proceed at once: reset the task cooldown, which would
+ // otherwise swallow it when the previous run started less than TaskDuration ago.
+ this.GetStateByGameContext(gameContext).LastRunUtc = DateTime.MinValue;
+
+ await base.DisposeRunningGamesAsync(gameContext).ConfigureAwait(false);
+ }
+
///
protected override KanturuGameServerState CreateState(IGameContext gameContext)
{
return new KanturuGameServerState(gameContext);
}
+
+ ///
+ protected override bool IsPreviousEventStillRunning(KanturuGameServerState state)
+ {
+ // While the tower window is open, the regular schedule must not start a new
+ // event — not even when no game currently runs, e.g. after a server restart.
+ // The window is read live, so this also works where no asynchronous call is possible.
+ if (KanturuTowerWindow.GetOpenUntilUtc(state.Context) is { } until && until > DateTime.UtcNow)
+ {
+ return true;
+ }
+
+ // A hosted tower doesn't block the next run on its own; only the open window
+ // does (see above). A leftover tower game is disposed in OnStartedAsync.
+ return base.IsPreviousEventStillRunning(state)
+ && state.Context.MiniGames.GetRunningMiniGames(MiniGameType.Kanturu)
+ .Any(game => game is not KanturuContext tower || !tower.TowerMode);
+ }
+
+ ///
+ protected override async ValueTask OnStartedAsync(KanturuGameServerState state)
+ {
+ // Hygiene for stale tower games, e.g. a tower whose window just expired while
+ // its exit phases still run: the new event must not reuse their map.
+ foreach (var game in state.Context.MiniGames.GetRunningMiniGames(MiniGameType.Kanturu))
+ {
+ if (game is KanturuContext { TowerMode: true })
+ {
+ await game.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ await KanturuTowerWindow.SetOpenUntilUtcAsync(
+ state.Context,
+ null,
+ state.Context.LoggerFactory.CreateLogger(this.GetType())).ConfigureAwait(false);
+
+ await base.OnStartedAsync(state).ConfigureAwait(false);
+ }
}
diff --git a/src/GameLogic/PlugIns/PeriodicTasks/MiniGameStartBasePlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/MiniGameStartBasePlugIn.cs
index 5d35e3df5..31456f643 100644
--- a/src/GameLogic/PlugIns/PeriodicTasks/MiniGameStartBasePlugIn.cs
+++ b/src/GameLogic/PlugIns/PeriodicTasks/MiniGameStartBasePlugIn.cs
@@ -32,7 +32,7 @@ public bool IsEventActive(IGameContext gameContext)
}
///
- public async ValueTask DisposeRunningGamesAsync(IGameContext gameContext)
+ public virtual async ValueTask DisposeRunningGamesAsync(IGameContext gameContext)
{
Announcements.TryRemove((this.GetType(), gameContext), out _);
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType());
@@ -52,7 +52,7 @@ public async ValueTask DisposeRunningGamesAsync(IGameContext gameContext)
}
///
- public ValueTask GetDurationUntilNextStartAsync(IGameContext gameContext, MiniGameDefinition miniGameDefinition)
+ public virtual ValueTask GetDurationUntilNextStartAsync(IGameContext gameContext, MiniGameDefinition miniGameDefinition)
{
var state = this.GetStateByGameContext(gameContext);
if (state.State == PeriodicTaskState.Prepared)
@@ -212,6 +212,12 @@ protected override ValueTask OnFinishedAsync(TGameState state)
return ValueTask.CompletedTask;
}
+ private static bool IsActive(MiniGameContext game)
+ {
+ return !game.IsDisposed && !game.IsDisposing
+ && game.State is MiniGameState.Open or MiniGameState.Closed or MiniGameState.Playing;
+ }
+
///
/// Disposes previously started games which already ended, so that starting a new event
/// always creates fresh game instances instead of reusing a stale one.
@@ -228,12 +234,6 @@ private async ValueTask DisposeStaleGamesAsync(IGameContext gameContext)
}
}
- private static bool IsActive(MiniGameContext game)
- {
- return !game.IsDisposed && !game.IsDisposing
- && game.State is MiniGameState.Open or MiniGameState.Closed or MiniGameState.Playing;
- }
-
///
/// Tracks the entrance announcements of one run: which minute was announced last, and
/// whether the closing has been announced.
diff --git a/src/GameLogic/Properties/PlayerMessage.resx b/src/GameLogic/Properties/PlayerMessage.resx
index d04c613c4..35812eba2 100644
--- a/src/GameLogic/Properties/PlayerMessage.resx
+++ b/src/GameLogic/Properties/PlayerMessage.resx
@@ -442,10 +442,10 @@
NIGHTMARE has appeared! Defeat him to claim victory!
- Nightmare has teleported! He recovers his full strength!
+ Nightmare has teleported! Don't let him escape!
- Nightmare teleports again! He is more powerful than ever!
+ Nightmare teleports again! Keep up the attack!
Nightmare is at his last stand! Finish him!
diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs
index 02d82f39d..1f28d5553 100644
--- a/src/GameLogic/Properties/PlugInResources.Designer.cs
+++ b/src/GameLogic/Properties/PlugInResources.Designer.cs
@@ -1410,6 +1410,42 @@ public static string MiniGameStartConfiguration_EntranceOpenedMessage_Name {
}
}
+ ///
+ /// Looks up a localized string similar to Tower open duration.
+ ///
+ public static string KanturuStartConfiguration_TowerOpenDuration_Name {
+ get {
+ return ResourceManager.GetString("KanturuStartConfiguration_TowerOpenDuration_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to How long the Tower of Refinement stays open after the Nightmare boss has been defeated..
+ ///
+ public static string KanturuStartConfiguration_TowerOpenDuration_Description {
+ get {
+ return ResourceManager.GetString("KanturuStartConfiguration_TowerOpenDuration_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Tower open until (UTC).
+ ///
+ public static string KanturuStartConfiguration_TowerOpenUntilUtc_Name {
+ get {
+ return ResourceManager.GetString("KanturuStartConfiguration_TowerOpenUntilUtc_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to End of the current open window in UTC. Set automatically when Nightmare is defeated..
+ ///
+ public static string KanturuStartConfiguration_TowerOpenUntilUtc_Description {
+ get {
+ return ResourceManager.GetString("KanturuStartConfiguration_TowerOpenUntilUtc_Description", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Increases all monster base stats by a configurable percentage..
///
diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx
index a5b2ce76a..7ce858d79 100644
--- a/src/GameLogic/Properties/PlugInResources.resx
+++ b/src/GameLogic/Properties/PlugInResources.resx
@@ -1218,6 +1218,18 @@
Entrance closed message
+
+ Tower open duration
+
+
+ How long the Tower of Refinement stays open after the Nightmare boss has been defeated.
+
+
+ Tower open until (UTC)
+
+
+ End of the current open window in UTC. Set automatically when Nightmare is defeated.
+
Experience multiplier
diff --git a/src/GameServer/MessageHandler/MiniGames/KanturuEnterRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/MiniGames/KanturuEnterRequestHandlerPlugIn.cs
index 393a6a0c6..1ff36b3a4 100644
--- a/src/GameServer/MessageHandler/MiniGames/KanturuEnterRequestHandlerPlugIn.cs
+++ b/src/GameServer/MessageHandler/MiniGames/KanturuEnterRequestHandlerPlugIn.cs
@@ -52,9 +52,17 @@ public async ValueTask HandlePacketAsync(Player player, Memory packet)
}
// Try to enter the Kanturu mini game.
+ // While the tower window is open without a running event, this recreates the
+ // tower first, so the generic entry below finds a joinable game. Otherwise the
+ // generic entry handles everything, including clean rejections.
// On success: the player is teleported to the event map.
// On failure: TryEnterMiniGameAsync shows a message to the player and the client
// NPC animation resets naturally at frame 50, so the dialog stays usable.
+ if (player.GetSuitableMiniGameDefinition(MiniGameType.Kanturu, 1) is { } miniGameDefinition)
+ {
+ await KanturuTowerEntry.EnsureTowerGameAsync(player, miniGameDefinition).ConfigureAwait(false);
+ }
+
await this._enterAction.TryEnterMiniGameAsync(player, MiniGameType.Kanturu, 1, UndefinedTicketSlot)
.ConfigureAwait(false);
diff --git a/src/GameServer/RemoteView/MiniGames/Extensions.cs b/src/GameServer/RemoteView/MiniGames/Extensions.cs
index 684ff9ae4..e71dc2a3f 100644
--- a/src/GameServer/RemoteView/MiniGames/Extensions.cs
+++ b/src/GameServer/RemoteView/MiniGames/Extensions.cs
@@ -87,4 +87,16 @@ public static DoppelgangerEnterResult.EnterResult ToDoppelgangerEnterResult(this
_ => DoppelgangerEnterResult.EnterResult.Failed,
};
}
+
+ ///
+ /// Converts the to the corresponding .
+ ///
+ /// The enter result.
+ /// The converted result.
+ public static KanturuEnterResult.EnterResult ToKanturuEnterResult(this EnterResult enterResult)
+ {
+ return enterResult == EnterResult.Success
+ ? KanturuEnterResult.EnterResult.Success
+ : KanturuEnterResult.EnterResult.Failed;
+ }
}
diff --git a/src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs b/src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs
index 013f86cec..27c4fb64b 100644
--- a/src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs
+++ b/src/GameServer/RemoteView/MiniGames/ShowMiniGameEnterResultViewPlugIn.cs
@@ -44,6 +44,9 @@ public async ValueTask ShowResultAsync(MiniGameType miniGameType, EnterResult en
case MiniGameType.Doppelganger:
await this._player.Connection.SendDoppelgangerEnterResultAsync(enterResult.ToDoppelgangerEnterResult()).ConfigureAwait(false);
break;
+ case MiniGameType.Kanturu:
+ await this._player.Connection.SendKanturuEnterResultAsync(enterResult.ToKanturuEnterResult()).ConfigureAwait(false);
+ break;
case MiniGameType.Undefined:
throw new ArgumentException("undefined game type", nameof(miniGameType));
default:
diff --git a/src/Persistence/Initialization/Updates/RefreshKanturuDataUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/RefreshKanturuDataUpdatePlugIn.cs
new file mode 100644
index 000000000..2400912c2
--- /dev/null
+++ b/src/Persistence/Initialization/Updates/RefreshKanturuDataUpdatePlugIn.cs
@@ -0,0 +1,178 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Persistence.Initialization.Updates;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
+using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix.Maps;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Refreshes all Kanturu Refinery Tower data of an existing Season 6 database in
+/// one go: the event map safezone, the participant limit, and the Nightmare summon
+/// waves. The start configuration itself is deleted once, so it's recreated from
+/// scratch with defaults on the next startup; no JSON migration is needed.
+/// Every other step only fills in missing or seeded values, so customized values
+/// are preserved and re-running stays a no-op.
+///
+[PlugIn]
+[Display(Name = PlugInName, Description = PlugInDescription)]
+[Guid("E5AFDD7A-3DE8-4955-8BB5-4231F6A87749")]
+public class RefreshKanturuDataUpdatePlugIn : UpdatePlugInBase
+{
+ ///
+ /// The plug-in name.
+ ///
+ internal const string PlugInName = "Refresh Kanturu data";
+
+ ///
+ /// The plug-in description.
+ ///
+ internal const string PlugInDescription = "Sets the Kanturu event safezone to Kanturu Relics, seeds 15 participants and the Nightmare summon waves, and resets the start configuration to defaults; customized values of the remaining data are preserved.";
+
+ ///
+ /// The first wave number of the Nightmare summons.
+ ///
+ internal const byte FirstSummonWaveNumber = 9;
+
+ private const int PreviousMaximumPlayerCount = 10;
+
+ private const int CurrentMaximumPlayerCount = 15;
+
+ ///
+ public override string Name => PlugInName;
+
+ ///
+ public override string Description => PlugInDescription;
+
+ ///
+ public override UpdateVersion Version => UpdateVersion.RefreshKanturuData;
+
+ ///
+ public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id;
+
+ ///
+ public override bool IsMandatory => false;
+
+ ///
+ public override DateTime CreatedAt => new(2026, 09, 25, 12, 0, 0, DateTimeKind.Utc);
+
+ ///
+ protected override async ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration)
+ {
+ FixSafezoneMap(gameConfiguration);
+ FixMaximumPlayerCount(gameConfiguration);
+ await DeleteStartConfigurationAsync(context, gameConfiguration).ConfigureAwait(false);
+ new KanturuSummonWaveSeeder(context, gameConfiguration).Seed();
+ }
+
+ ///
+ /// Sets the safezone of the Kanturu event map to Kanturu Relics, so that players
+ /// who die inside the event respawn there instead of on the event map itself.
+ ///
+ /// The game configuration.
+ private static void FixSafezoneMap(GameConfiguration gameConfiguration)
+ {
+ if (gameConfiguration.Maps.FirstOrDefault(m => m.Number == KanturuEvent.Number) is { } eventMap
+ && eventMap.SafezoneMap is not { Number: KanturuRelics.Number })
+ {
+ eventMap.SafezoneMap = gameConfiguration.Maps.FirstOrDefault(m => m.Number == KanturuRelics.Number);
+ }
+ }
+
+ ///
+ /// Deletes the persisted start configuration entirely, so it's recreated from
+ /// scratch with defaults on the next startup. This replaces JSON migration:
+ /// whatever archaeology the row holds is discarded once, and the update never
+ /// needs to run again.
+ ///
+ /// The persistence context.
+ /// The game configuration.
+ private static async ValueTask DeleteStartConfigurationAsync(IContext context, GameConfiguration gameConfiguration)
+ {
+ foreach (var stale in gameConfiguration.PlugInConfigurations
+ .Where(c => c.TypeId == typeof(KanturuStartPlugIn).GUID)
+ .ToList())
+ {
+ await context.DeleteAsync(stale).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Raises a seeded participant limit to 15.
+ /// Customized values are preserved.
+ ///
+ /// The game configuration.
+ private static void FixMaximumPlayerCount(GameConfiguration gameConfiguration)
+ {
+ if (gameConfiguration.MiniGameDefinitions.FirstOrDefault(definition => definition.Type == MiniGameType.Kanturu && definition.GameLevel == 1) is { } definition
+ && definition.MaximumPlayerCount == PreviousMaximumPlayerCount)
+ {
+ definition.MaximumPlayerCount = CurrentMaximumPlayerCount;
+ }
+ }
+
+ ///
+ /// Adds the summon waves which are missing from the map definition.
+ ///
+ private sealed class KanturuSummonWaveSeeder : KanturuEvent
+ {
+ public KanturuSummonWaveSeeder(IContext context, GameConfiguration gameConfiguration)
+ : base(context, gameConfiguration)
+ {
+ }
+
+ public void Seed()
+ {
+ if (this.GameConfiguration.Maps.FirstOrDefault(m => m.Number == Number) is not { } map)
+ {
+ return;
+ }
+
+ foreach (var (number, monsterNumber, x1, x2, y1, y2, quantity, waveNumber) in EventWaveSpawns)
+ {
+ if (waveNumber < FirstSummonWaveNumber)
+ {
+ continue;
+ }
+
+ if (!this.NpcDictionary.TryGetValue(monsterNumber, out var monster))
+ {
+ throw new InvalidOperationException($"Kanturu summon wave {waveNumber} needs monster {monsterNumber}, which is missing in the game configuration.");
+ }
+
+ this.AddSummonSpawn(map, number, monster, x1, x2, y1, y2, quantity, waveNumber);
+ }
+ }
+
+ ///
+ /// Creates one summon spawn area and adds it to the map, unless the wave is
+ /// already there. It mirrors the wave spawn creation of .
+ ///
+ private void AddSummonSpawn(GameMapDefinition map, short number, MonsterDefinition monster, byte x1, byte x2, byte y1, byte y2, short quantity, byte waveNumber)
+ {
+ if (map.MonsterSpawns.Any(s => s.WaveNumber == waveNumber && s.SpawnTrigger == SpawnTrigger.OnceAtWaveStart))
+ {
+ // Already there: running the update multiple times must not create duplicates.
+ return;
+ }
+
+ var area = this.Context.CreateNew();
+ area.SetGuid(map.Number, number);
+ area.GameMap = map;
+ area.MonsterDefinition = monster;
+ area.Quantity = quantity;
+ area.Direction = Direction.Undefined;
+ area.SpawnTrigger = SpawnTrigger.OnceAtWaveStart;
+ area.X1 = x1;
+ area.X2 = x2;
+ area.Y1 = y1;
+ area.Y2 = y2;
+ area.WaveNumber = waveNumber;
+ map.MonsterSpawns.Add(area);
+ }
+ }
+}
diff --git a/src/Persistence/Initialization/Updates/UpdateVersion.cs b/src/Persistence/Initialization/Updates/UpdateVersion.cs
index a94614854..0038b60c0 100644
--- a/src/Persistence/Initialization/Updates/UpdateVersion.cs
+++ b/src/Persistence/Initialization/Updates/UpdateVersion.cs
@@ -584,4 +584,9 @@ public enum UpdateVersion
/// The version of the .
///
AddDoppelgangerData = 115,
+
+ ///
+ /// The version of the .
+ ///
+ RefreshKanturuData = 116,
}
diff --git a/src/Persistence/Initialization/VersionSeasonSix/Events/KanturuInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/Events/KanturuInitializer.cs
index ef4de7dbe..a333e8137 100644
--- a/src/Persistence/Initialization/VersionSeasonSix/Events/KanturuInitializer.cs
+++ b/src/Persistence/Initialization/VersionSeasonSix/Events/KanturuInitializer.cs
@@ -33,7 +33,7 @@ public override void Initialize()
kanturu.EnterDuration = TimeSpan.FromMinutes(3);
kanturu.GameDuration = TimeSpan.FromMinutes(135);
kanturu.ExitDuration = TimeSpan.FromMinutes(1);
- kanturu.MaximumPlayerCount = 10;
+ kanturu.MaximumPlayerCount = 15;
kanturu.MinimumCharacterLevel = 350;
kanturu.MaximumCharacterLevel = 400;
kanturu.MinimumSpecialCharacterLevel = 350;
diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/KanturuEvent.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/KanturuEvent.cs
index 2641c3ea8..0040c9122 100644
--- a/src/Persistence/Initialization/VersionSeasonSix/Maps/KanturuEvent.cs
+++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/KanturuEvent.cs
@@ -105,6 +105,12 @@ public KanturuEvent(IContext context, GameConfiguration gameConfiguration)
// Wave 8: Nightmare.
(270, NightmareNumber, 78, 78, 143, 143, 1, 8),
+
+ // Waves 9-11: Nightmare summons — 7 Dread Fear around each teleport target
+ // of the health phases ((79, 100), (78, 124), (78, 141)).
+ (280, DreadfearNumber, 77, 81, 98, 102, 7, 9),
+ (281, DreadfearNumber, 76, 80, 122, 126, 7, 10),
+ (282, DreadfearNumber, 76, 80, 139, 143, 7, 11),
];
///
diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs
index 0995c234c..6100b9732 100644
--- a/src/Startup/Program.cs
+++ b/src/Startup/Program.cs
@@ -534,6 +534,7 @@ private IEnumerable CreateMissingPlugInConfigurations(IEnum
this.CreateDefaultPlugInConfiguration(plugInType, plugInConfiguration, referenceHandler);
}
+ this._logger.Information("Created missing plugin configuration for plugin type {plugInType}", plugInType);
yield return plugInConfiguration;
}
diff --git a/src/Web/Shared/Services/PersistentObjectsLookupController.cs b/src/Web/Shared/Services/PersistentObjectsLookupController.cs
index 59f9f6063..ecdcbaf77 100644
--- a/src/Web/Shared/Services/PersistentObjectsLookupController.cs
+++ b/src/Web/Shared/Services/PersistentObjectsLookupController.cs
@@ -60,8 +60,10 @@ public async Task> GetSuggestionsAsync(string? text, IContext?
? this._contextProvider.CreateNewContext(owner)
: null;
var effectiveContext = persistenceContext ?? context;
- if (effectiveContext is null)
+ if (effectiveContext is null || effectiveContext.IsSupporting(typeof(T)) is not true)
{
+ // Not a persisted entity (e.g. a game-logic definition class whose
+ // name merely contains "Definition"): nothing to suggest.
return Enumerable.Empty();
}
diff --git a/tests/MUnique.OpenMU.Tests/KanturuBarrierAreaHelperTests.cs b/tests/MUnique.OpenMU.Tests/KanturuBarrierAreaHelperTests.cs
new file mode 100644
index 000000000..de1704001
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/KanturuBarrierAreaHelperTests.cs
@@ -0,0 +1,75 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class KanturuBarrierAreaHelperTests
+{
+ ///
+ /// Tests that a single cell area returns exactly that cell.
+ ///
+ [Test]
+ public void EnumerateCells_SingleCell_ReturnsIt()
+ {
+ var areas = new List { new() { StartX = 73, StartY = 144, EndX = 73, EndY = 144 } };
+
+ var cells = KanturuBarrierAreaHelper.EnumerateCells(areas).ToList();
+
+ Assert.That(cells, Has.Count.EqualTo(1));
+ Assert.That(cells[0], Is.EqualTo(((byte)73, (byte)144)));
+ }
+
+ ///
+ /// Tests that a rectangle is enumerated inclusively.
+ ///
+ [Test]
+ public void EnumerateCells_Rectangle_IsInclusive()
+ {
+ var areas = new List { new() { StartX = 0, StartY = 0, EndX = 1, EndY = 2 } };
+
+ var cells = KanturuBarrierAreaHelper.EnumerateCells(areas).ToList();
+
+ Assert.That(cells, Has.Count.EqualTo(6));
+ }
+
+ ///
+ /// Tests that the default barrier area contains the expected cell count.
+ ///
+ [Test]
+ public void EnumerateCells_DefaultBarrierArea_MatchesExpectedCount()
+ {
+ // X=73-90 (18), Y=144-195 (52) => 936 cells.
+ var areas = new List { new() { StartX = 73, StartY = 144, EndX = 90, EndY = 195 } };
+
+ Assert.That(KanturuBarrierAreaHelper.EnumerateCells(areas).Count(), Is.EqualTo(18 * 52));
+ }
+
+ ///
+ /// Tests that no areas return no cells.
+ ///
+ [Test]
+ public void EnumerateCells_Empty_ReturnsNone()
+ {
+ Assert.That(KanturuBarrierAreaHelper.EnumerateCells(new List()), Is.Empty);
+ }
+
+ ///
+ /// Tests that an area touching the map border terminates instead of wrapping around.
+ ///
+ [Test, Timeout(5000)]
+ public void EnumerateCells_MaxBoundary_Terminates()
+ {
+ var areas = new List { new() { StartX = 254, StartY = 254, EndX = 255, EndY = 255 } };
+
+ var cells = KanturuBarrierAreaHelper.EnumerateCells(areas).ToList();
+
+ Assert.That(cells, Has.Count.EqualTo(4));
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/KanturuEventDefinitionTests.cs b/tests/MUnique.OpenMU.Tests/KanturuEventDefinitionTests.cs
new file mode 100644
index 000000000..f98001cce
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/KanturuEventDefinitionTests.cs
@@ -0,0 +1,145 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using Moq;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+///
+/// Tests for the default .
+/// The expected timers and kill targets come from new-requirements.txt:
+/// each wave (monsters + boss together) shares one clock — 15 minutes for
+/// waves 1-2, 20 minutes for waves 3-4.
+///
+[TestFixture]
+public class KanturuEventDefinitionTests
+{
+ private readonly KanturuEventDefinition _definition = KanturuEventDefinition.CreateDefault(CreateGameConfiguration());
+
+ ///
+ /// Tests that the first phase of each wave carries the shared wave time limit.
+ ///
+ /// The name of the phase.
+ /// The expected time limit in minutes.
+ [TestCase("Phase 1 - Monsters", 15)]
+ [TestCase("Phase 2 - Monsters", 15)]
+ [TestCase("Phase 3 - Monsters", 20)]
+ [TestCase("Nightmare - Guardians", 20)]
+ public void WaveStartPhase_HasRequiredTimeLimit(string phaseName, int expectedMinutes)
+ {
+ var phase = this._definition.Phases.First(phase => phase.Name == phaseName);
+
+ Assert.That(phase.TimeLimit, Is.EqualTo(TimeSpan.FromMinutes(expectedMinutes)));
+ }
+
+ ///
+ /// Tests that the first phase of each wave starts a shared wave clock.
+ ///
+ /// The name of the phase.
+ [TestCase("Phase 1 - Monsters")]
+ [TestCase("Phase 2 - Monsters")]
+ [TestCase("Phase 3 - Monsters")]
+ [TestCase("Nightmare - Guardians")]
+ public void WaveStartPhase_StartsSharedWaveClock(string phaseName)
+ {
+ var phase = this._definition.Phases.First(phase => phase.Name == phaseName);
+
+ Assert.That(phase.TimeLimitGroup, Is.Not.Null);
+ }
+
+ ///
+ /// Tests that the boss phases carry no own time limit.
+ ///
+ /// The name of the phase.
+ [TestCase("Phase 1 - Maya's left hand")]
+ [TestCase("Phase 2 - Maya's right hand")]
+ [TestCase("Phase 3 - Both hands of Maya")]
+ [TestCase("Nightmare")]
+ public void BossPhase_HasNoOwnTimeLimit(string phaseName)
+ {
+ var phase = this._definition.Phases.First(phase => phase.Name == phaseName);
+
+ Assert.That(phase.TimeLimit, Is.Null);
+ }
+
+ ///
+ /// Tests that the boss phases inherit the remaining wave time instead of
+ /// getting a fresh timer.
+ ///
+ /// The name of the phase.
+ /// The expected shared wave clock.
+ [TestCase("Phase 1 - Maya's left hand", KanturuWaveGroup.MayaLeftHand)]
+ [TestCase("Phase 2 - Maya's right hand", KanturuWaveGroup.MayaRightHand)]
+ [TestCase("Phase 3 - Both hands of Maya", KanturuWaveGroup.MayaBothHands)]
+ [TestCase("Nightmare", KanturuWaveGroup.Nightmare)]
+ public void BossPhase_InheritsSharedWaveTime(string phaseName, KanturuWaveGroup expectedGroup)
+ {
+ var phase = this._definition.Phases.First(phase => phase.Name == phaseName);
+
+ Assert.That(phase.TimeLimitGroup, Is.EqualTo(expectedGroup));
+ }
+
+ ///
+ /// Tests that a Nightmare phase definition exists.
+ ///
+ [Test]
+ public void NightmarePhaseDefinition_Exists()
+ {
+ var nightmare = this._definition.Phases.First(phase => phase.Kind == KanturuPhaseKind.Nightmare).Nightmare;
+
+ Assert.That(nightmare, Is.Not.Null);
+ }
+
+ ///
+ /// Tests that every Nightmare health phase spawns its summon wave.
+ ///
+ [Test]
+ public void NightmareHpPhases_SummonConfiguredWaves()
+ {
+ var nightmare = this._definition.Phases.First(phase => phase.Kind == KanturuPhaseKind.Nightmare).Nightmare;
+
+ Assert.That(nightmare!.HpPhases.Select(phase => phase.SummonWaveNumber), Is.EqualTo(new byte?[] { 9, 10, 11 }));
+ }
+
+ ///
+ /// Tests that the Maya hand standbys allow refilling up to 15 players.
+ ///
+ /// The name of the phase.
+ [TestCase("Phase 1 - Maya's left hand")]
+ [TestCase("Phase 2 - Maya's right hand")]
+ public void MayaHandPhase_HasTwoMinuteStandby_ForRefills(string phaseName)
+ {
+ var phase = this._definition.Phases.First(phase => phase.Name == phaseName);
+
+ Assert.That(phase.StandbyDuration, Is.EqualTo(TimeSpan.FromMinutes(2)));
+ }
+
+ ///
+ /// Tests that the wave kill targets match the wave details (40/40/20).
+ ///
+ /// The name of the phase.
+ /// The expected kill target.
+ [TestCase("Phase 1 - Monsters", 40)]
+ [TestCase("Phase 1 - Maya's left hand", 1)]
+ [TestCase("Phase 2 - Monsters", 40)]
+ [TestCase("Phase 2 - Maya's right hand", 1)]
+ [TestCase("Phase 3 - Monsters", 20)]
+ [TestCase("Phase 3 - Both hands of Maya", 2)]
+ [TestCase("Nightmare", 1)]
+ public void DefaultPhase_HasRequiredKillTarget(string phaseName, int expectedKillTarget)
+ {
+ var phase = this._definition.Phases.First(phase => phase.Name == phaseName);
+
+ Assert.That(phase.KillTarget, Is.EqualTo(expectedKillTarget));
+ }
+
+ private static GameConfiguration CreateGameConfiguration()
+ {
+ var gameConfiguration = new Mock();
+ gameConfiguration.Setup(c => c.Monsters).Returns(new List());
+ return gameConfiguration.Object;
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/KanturuKillTrackerTests.cs b/tests/MUnique.OpenMU.Tests/KanturuKillTrackerTests.cs
new file mode 100644
index 000000000..8a1f4ece0
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/KanturuKillTrackerTests.cs
@@ -0,0 +1,177 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class KanturuKillTrackerTests
+{
+ private static KanturuPhaseDefinition CreateWavePhase(int killTarget = 2)
+ {
+ return new KanturuPhaseDefinition
+ {
+ Name = "wave",
+ Kind = KanturuPhaseKind.MonsterWave,
+ KillTarget = killTarget,
+ CountedMonsters = new List { new() { Number = 354 } },
+ };
+ }
+
+ ///
+ /// Tests that kills without a current phase are ignored.
+ ///
+ [Test]
+ public void RegisterKill_WithoutPhase_IsIgnored()
+ {
+ var tracker = new KanturuKillTracker();
+
+ var result = tracker.RegisterKill(new MonsterDefinition { Number = 354 });
+
+ Assert.That(result.Counted, Is.False);
+ Assert.That(result.PhaseComplete, Is.False);
+ }
+
+ ///
+ /// Tests that the phase completes once the kill target is reached.
+ ///
+ [Test]
+ public void RegisterKill_CountsUntilTarget_ThenCompletes()
+ {
+ var tracker = new KanturuKillTracker();
+ tracker.BeginPhase(CreateWavePhase());
+
+ var first = tracker.RegisterKill(new MonsterDefinition { Number = 354 });
+ var second = tracker.RegisterKill(new MonsterDefinition { Number = 354 });
+
+ Assert.That(first.Counted, Is.True);
+ Assert.That(first.PhaseComplete, Is.False);
+ Assert.That(second.Counted, Is.True);
+ Assert.That(second.PhaseComplete, Is.True);
+ Assert.That(tracker.PhaseCompleted.IsCompleted, Is.True);
+ }
+
+ ///
+ /// Tests that kills of other monsters don't change the kill count.
+ ///
+ [Test]
+ public void RegisterKill_OtherMonster_IsIgnored()
+ {
+ var tracker = new KanturuKillTracker();
+ tracker.BeginPhase(CreateWavePhase());
+
+ var result = tracker.RegisterKill(new MonsterDefinition { Number = 999 });
+
+ Assert.That(result.Counted, Is.False);
+ Assert.That(tracker.KillCount, Is.EqualTo(0));
+ }
+
+ ///
+ /// Tests that killing the Nightmare boss sets the boss flag.
+ ///
+ [Test]
+ public void RegisterKill_NightmareBoss_SetsBossFlag()
+ {
+ var boss = new MonsterDefinition { Number = 361 };
+ var phase = new KanturuPhaseDefinition
+ {
+ Name = "nightmare",
+ Kind = KanturuPhaseKind.Nightmare,
+ KillTarget = 1,
+ CountedMonsters = new List { boss },
+ Nightmare = new KanturuNightmareDefinition { Monster = new MonsterDefinition { Number = 361 } },
+ };
+ var tracker = new KanturuKillTracker();
+ tracker.BeginPhase(phase);
+
+ var result = tracker.RegisterKill(new MonsterDefinition { Number = 361 });
+
+ Assert.That(result.Counted, Is.True);
+ Assert.That(result.NightmareBossKilled, Is.True);
+ Assert.That(result.IsNightmarePhase, Is.True);
+ Assert.That(result.PhaseComplete, Is.True);
+ Assert.That(result.Phase, Is.SameAs(phase));
+ }
+
+ ///
+ /// Tests that starting a new phase resets the count and the completion.
+ ///
+ [Test]
+ public void BeginPhase_ResetsCount_AndClearsCompletion()
+ {
+ var tracker = new KanturuKillTracker();
+ tracker.BeginPhase(CreateWavePhase(killTarget: 1));
+ tracker.RegisterKill(new MonsterDefinition { Number = 354 });
+
+ tracker.BeginPhase(CreateWavePhase(killTarget: 5));
+
+ Assert.That(tracker.KillCount, Is.EqualTo(0));
+ Assert.That(tracker.PhaseCompleted.IsCompleted, Is.False);
+ }
+
+ ///
+ /// Tests that kills are ignored after the phase has been cleared.
+ ///
+ [Test]
+ public void ClearPhase_KillsAreIgnored()
+ {
+ var tracker = new KanturuKillTracker();
+ tracker.BeginPhase(CreateWavePhase());
+ tracker.ClearPhase();
+
+ var result = tracker.RegisterKill(new MonsterDefinition { Number = 354 });
+
+ Assert.That(result.Counted, Is.False);
+ Assert.That(result.Phase, Is.Null);
+ }
+
+ ///
+ /// Tests that concurrent kills count exactly once each and complete the phase.
+ ///
+ [Test]
+ public async Task RegisterKill_FromMultipleThreads_CountsExactlyOnce()
+ {
+ var tracker = new KanturuKillTracker();
+ tracker.BeginPhase(CreateWavePhase(killTarget: 2000));
+ var monster = new MonsterDefinition { Number = 354 };
+
+ await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
+ {
+ for (var i = 0; i < 250; i++)
+ {
+ tracker.RegisterKill(monster);
+ }
+ }))).ConfigureAwait(false);
+
+ Assert.That(tracker.KillCount, Is.EqualTo(2000));
+ Assert.That(tracker.PhaseCompleted.IsCompleted, Is.True);
+ }
+
+ ///
+ /// Tests that a completed phase's kills don't leak into the next generation.
+ ///
+ [Test]
+ public void BeginPhase_AfterCompletedPhase_StartsIsolatedGeneration()
+ {
+ var tracker = new KanturuKillTracker();
+ tracker.BeginPhase(CreateWavePhase(killTarget: 1));
+ tracker.RegisterKill(new MonsterDefinition { Number = 354 });
+
+ tracker.BeginPhase(CreateWavePhase(killTarget: 2));
+
+ Assert.That(tracker.KillCount, Is.EqualTo(0));
+ Assert.That(tracker.PhaseCompleted.IsCompleted, Is.False);
+
+ var result = tracker.RegisterKill(new MonsterDefinition { Number = 354 });
+
+ Assert.That(result.Counted, Is.True);
+ Assert.That(result.KillCount, Is.EqualTo(1));
+ Assert.That(result.PhaseComplete, Is.False);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/KanturuMayaWideAttackerTests.cs b/tests/MUnique.OpenMU.Tests/KanturuMayaWideAttackerTests.cs
new file mode 100644
index 000000000..42537728f
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/KanturuMayaWideAttackerTests.cs
@@ -0,0 +1,120 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using System.Threading;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+using MUnique.OpenMU.GameLogic.NPC;
+using MonsterAttribute = MUnique.OpenMU.Persistence.BasicModel.MonsterAttribute;
+using MonsterDefinition = MUnique.OpenMU.Persistence.BasicModel.MonsterDefinition;
+
+///
+/// Tests for the attacker selection.
+///
+[TestFixture]
+public class KanturuMayaWideAttackerTests
+{
+ private IGameContext _gameContext = null!;
+
+ private GameMap _map = null!;
+
+ ///
+ /// Sets up a fresh game context and map before each test.
+ ///
+ [SetUp]
+ public async Task SetUpAsync()
+ {
+ this._gameContext = GameContextTestHelper.CreateGameContext();
+ this._map = (await this._gameContext.GetMapAsync(0).ConfigureAwait(false))!;
+ }
+
+ ///
+ /// Tests that a living Maya monster is preferred over other monsters.
+ ///
+ [Test]
+ public void FindAttacker_PrefersLivingMayaMonster()
+ {
+ var minion = this.CreateMonster(354, initialize: true);
+ var hand = this.CreateMonster(362, initialize: true);
+
+ Assert.That(KanturuMayaWideAttacker.FindAttacker([minion, hand]), Is.SameAs(hand));
+ }
+
+ ///
+ /// Tests that another living monster is used when no Maya monster is alive.
+ ///
+ [Test]
+ public void FindAttacker_FallsBackToOtherLivingMonster()
+ {
+ var deadHand = this.CreateMonster(362, initialize: false);
+ var minion = this.CreateMonster(354, initialize: true);
+
+ Assert.That(KanturuMayaWideAttacker.FindAttacker([deadHand, minion]), Is.SameAs(minion));
+ }
+
+ ///
+ /// Tests that no attacker is found when no monster is alive.
+ ///
+ [Test]
+ public void FindAttacker_WithoutLivingMonster_ReturnsNull()
+ {
+ var deadHand = this.CreateMonster(362, initialize: false);
+
+ Assert.That(KanturuMayaWideAttacker.FindAttacker([deadHand]), Is.Null);
+ Assert.That(KanturuMayaWideAttacker.FindAttacker([]), Is.Null);
+ }
+
+ ///
+ /// Tests that the attacker stops without any broadcast when already cancelled.
+ ///
+ [Test]
+ public async Task RunAsync_StopsImmediately_WhenCancelled()
+ {
+ static ValueTask FailOnBroadcast(Func _)
+ {
+ throw new InvalidOperationException("Must not broadcast when cancelled.");
+ }
+
+ var attacker = new KanturuMayaWideAttacker(this._map, FailOnBroadcast, NullLogger.Instance);
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync().ConfigureAwait(false);
+
+ await attacker.RunAsync(TimeSpan.FromMilliseconds(10), () => false, cts.Token).ConfigureAwait(false);
+ }
+
+ private Monster CreateMonster(short number, bool initialize)
+ {
+ var definition = new MonsterDefinition { ObjectKind = NpcObjectKind.Monster, Number = number };
+ definition.Attributes.Add(new MonsterAttribute { AttributeDefinition = Stats.MaximumHealth, Value = 1000 });
+ var spawnArea = new MonsterSpawnArea
+ {
+ MonsterDefinition = definition,
+ X1 = 100,
+ Y1 = 100,
+ X2 = 100,
+ Y2 = 100,
+ Quantity = 1,
+ };
+ var monster = new Monster(
+ spawnArea,
+ definition,
+ this._map,
+ NullDropGenerator.Instance,
+ new Mock().Object,
+ this._gameContext.PlugInManager,
+ this._gameContext.PathFinderPool);
+ if (initialize)
+ {
+ monster.Initialize();
+ }
+
+ return monster;
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/KanturuMonsterComparerTests.cs b/tests/MUnique.OpenMU.Tests/KanturuMonsterComparerTests.cs
new file mode 100644
index 000000000..75e83b5f4
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/KanturuMonsterComparerTests.cs
@@ -0,0 +1,87 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class KanturuMonsterComparerTests
+{
+ ///
+ /// Tests that definitions with the same number are considered the same monster.
+ ///
+ [Test]
+ public void IsSameMonster_SameNumberDifferentInstances_ReturnsTrue()
+ {
+ var first = new MonsterDefinition { Number = 362 };
+ var second = new MonsterDefinition { Number = 362 };
+
+ Assert.That(KanturuMonsterComparer.IsSameMonster(first, second), Is.True);
+ }
+
+ ///
+ /// Tests that definitions with different numbers are considered different monsters.
+ ///
+ [Test]
+ public void IsSameMonster_DifferentNumbers_ReturnsFalse()
+ {
+ var first = new MonsterDefinition { Number = 362 };
+ var second = new MonsterDefinition { Number = 363 };
+
+ Assert.That(KanturuMonsterComparer.IsSameMonster(first, second), Is.False);
+ }
+
+ ///
+ /// Tests that null definitions never match.
+ ///
+ [Test]
+ public void IsSameMonster_Null_ReturnsFalse()
+ {
+ Assert.That(KanturuMonsterComparer.IsSameMonster(null, new MonsterDefinition { Number = 362 }), Is.False);
+ Assert.That(KanturuMonsterComparer.IsSameMonster(new MonsterDefinition { Number = 362 }, null), Is.False);
+ Assert.That(KanturuMonsterComparer.IsSameMonster(null, null), Is.False);
+ }
+
+ ///
+ /// Tests that a configured monster counts towards the kill target.
+ ///
+ [Test]
+ public void IsCountedMonster_KillCounts_ReturnsTrue()
+ {
+ var phase = new KanturuPhaseDefinition
+ {
+ CountedMonsters = new List { new() { Number = 362 } },
+ };
+
+ Assert.That(KanturuMonsterComparer.IsCountedMonster(new MonsterDefinition { Number = 362 }, phase), Is.True);
+ }
+
+ ///
+ /// Tests that other monsters don't count towards the kill target.
+ ///
+ [Test]
+ public void IsCountedMonster_OtherMonster_ReturnsFalse()
+ {
+ var phase = new KanturuPhaseDefinition
+ {
+ CountedMonsters = new List { new() { Number = 362 } },
+ };
+
+ Assert.That(KanturuMonsterComparer.IsCountedMonster(new MonsterDefinition { Number = 999 }, phase), Is.False);
+ }
+
+ ///
+ /// Tests that no kill counts without a current phase.
+ ///
+ [Test]
+ public void IsCountedMonster_NullPhase_ReturnsFalse()
+ {
+ Assert.That(KanturuMonsterComparer.IsCountedMonster(new MonsterDefinition { Number = 362 }, null), Is.False);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/KanturuNightmarePhaseSelectorTests.cs b/tests/MUnique.OpenMU.Tests/KanturuNightmarePhaseSelectorTests.cs
new file mode 100644
index 000000000..87ad93c98
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/KanturuNightmarePhaseSelectorTests.cs
@@ -0,0 +1,64 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class KanturuNightmarePhaseSelectorTests
+{
+ private static List CreatePhases()
+ {
+ return new List
+ {
+ new() { HealthPercentage = 75 },
+ new() { HealthPercentage = 50 },
+ new() { HealthPercentage = 25 },
+ };
+ }
+
+ ///
+ /// Tests that health percentages map to the expected phase index.
+ ///
+ /// The current health percentage.
+ /// The expected target phase index.
+ [TestCase(100f, 0)]
+ [TestCase(76f, 0)]
+ [TestCase(74f, 1)]
+ [TestCase(51f, 1)]
+ [TestCase(49f, 2)]
+ [TestCase(26f, 2)]
+ [TestCase(24f, 3)]
+ [TestCase(0f, 3)]
+ public void GetTargetPhaseIndex_MapsHealthToPhase(float health, int expected)
+ {
+ Assert.That(KanturuNightmarePhaseSelector.GetTargetPhaseIndex(CreatePhases(), health), Is.EqualTo(expected));
+ }
+
+ ///
+ /// Tests that a phase only starts below its threshold, not exactly at it.
+ ///
+ [Test]
+ public void GetTargetPhaseIndex_ExactlyAtThreshold_DoesNotTrigger()
+ {
+ // The original loop uses <, not <=.
+ Assert.That(KanturuNightmarePhaseSelector.GetTargetPhaseIndex(CreatePhases(), 75f), Is.EqualTo(0));
+ Assert.That(KanturuNightmarePhaseSelector.GetTargetPhaseIndex(CreatePhases(), 50f), Is.EqualTo(1));
+ }
+
+ ///
+ /// Tests that the monitor only advances to higher phase indexes.
+ ///
+ [Test]
+ public void ShouldAdvance_OnlyWhenTargetGreater()
+ {
+ Assert.That(KanturuNightmarePhaseSelector.ShouldAdvance(1, 0), Is.True);
+ Assert.That(KanturuNightmarePhaseSelector.ShouldAdvance(1, 1), Is.False);
+ Assert.That(KanturuNightmarePhaseSelector.ShouldAdvance(0, 1), Is.False);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/KanturuPhaseRunnerTests.cs b/tests/MUnique.OpenMU.Tests/KanturuPhaseRunnerTests.cs
new file mode 100644
index 000000000..65d7362d3
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/KanturuPhaseRunnerTests.cs
@@ -0,0 +1,411 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using System.Threading;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+using MUnique.OpenMU.GameLogic.NPC;
+using MonsterAttribute = MUnique.OpenMU.Persistence.BasicModel.MonsterAttribute;
+using MonsterDefinition = MUnique.OpenMU.Persistence.BasicModel.MonsterDefinition;
+
+///
+/// Tests for the Kanturu phase runners.
+///
+[TestFixture]
+public class KanturuPhaseRunnerTests
+{
+ private IGameContext _gameContext = null!;
+
+ private GameMap _map = null!;
+
+ ///
+ /// Sets up a fresh game context and map before each test.
+ ///
+ [SetUp]
+ public async Task SetUpAsync()
+ {
+ this._gameContext = GameContextTestHelper.CreateGameContext();
+ this._map = (await this._gameContext.GetMapAsync(0).ConfigureAwait(false))!;
+ }
+
+ ///
+ /// Tests that the wave runner executes its steps in order.
+ ///
+ [Test]
+ public async Task MonsterWaveRunner_RunsBeginAnnounceWaitStandby_InOrder()
+ {
+ var order = new List();
+ var runner = new KanturuMonsterWaveRunner(
+ (_, _) =>
+ {
+ order.Add("begin");
+ return Task.CompletedTask;
+ },
+ _ =>
+ {
+ order.Add("announce");
+ return Task.CompletedTask;
+ },
+ (_, _) =>
+ {
+ order.Add("wait");
+ return Task.FromResult(true);
+ },
+ (_, _) =>
+ {
+ order.Add("standby");
+ return Task.CompletedTask;
+ });
+
+ Assert.That(runner.Kind, Is.EqualTo(KanturuPhaseKind.MonsterWave));
+ var completed = await runner.RunAsync(new KanturuPhaseDefinition(), CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(completed, Is.True);
+ Assert.That(order, Is.EqualTo(new[] { "begin", "announce", "wait", "standby" }));
+ }
+
+ ///
+ /// Tests that the wave runner reports failure without standby when the wait fails.
+ ///
+ [Test]
+ public async Task MonsterWaveRunner_ReportsFailure_WithoutStandby()
+ {
+ var order = new List();
+ var runner = new KanturuMonsterWaveRunner(
+ (_, _) =>
+ {
+ order.Add("begin");
+ return Task.CompletedTask;
+ },
+ _ =>
+ {
+ order.Add("announce");
+ return Task.CompletedTask;
+ },
+ (_, _) =>
+ {
+ order.Add("wait");
+ return Task.FromResult(false);
+ },
+ (_, _) =>
+ {
+ order.Add("standby");
+ return Task.CompletedTask;
+ });
+
+ var completed = await runner.RunAsync(new KanturuPhaseDefinition(), CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(completed, Is.False);
+ Assert.That(order, Is.EqualTo(new[] { "begin", "announce", "wait" }));
+ }
+
+ ///
+ /// Tests that the transition runner clears the phase and shows the state.
+ ///
+ [Test]
+ public async Task TransitionRunner_ClearsPhase_AndShowsState()
+ {
+ KanturuState? shownState = null;
+ byte shownDetail = 0;
+ var cleared = false;
+ var playerCalls = 0;
+ var runner = new KanturuTransitionRunner(
+ (state, detail) =>
+ {
+ shownState = state;
+ shownDetail = detail;
+ return ValueTask.CompletedTask;
+ },
+ _ =>
+ {
+ playerCalls++;
+ return ValueTask.CompletedTask;
+ },
+ () => cleared = true);
+
+ Assert.That(runner.Kind, Is.EqualTo(KanturuPhaseKind.Transition));
+ var phase = new KanturuPhaseDefinition
+ {
+ State = KanturuState.MayaBattle,
+ DetailState = 7,
+ Transition = new KanturuTransitionDefinition { CinematicDuration = TimeSpan.Zero, WarpAnimationDelay = TimeSpan.Zero },
+ };
+ await runner.RunAsync(phase, CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(cleared, Is.True);
+ Assert.That(shownState, Is.EqualTo(KanturuState.MayaBattle));
+ Assert.That(shownDetail, Is.EqualTo(7));
+ Assert.That(playerCalls, Is.EqualTo(2));
+ }
+
+ ///
+ /// Tests that the nightmare runner arms the spawn capture before beginning the phase.
+ /// The boss spawns synchronously inside begin, so subscribing afterwards would miss it
+ /// and silently disable the health phases and special attacks.
+ ///
+ [Test]
+ public async Task NightmareRunner_ArmsSpawnCapture_BeforeBeginningPhase()
+ {
+ var order = new List();
+
+ // Records "subscribed" synchronously when the wait task is created,
+ // like the real waiter subscribes before its first await.
+ Task WaitForSpawn(KanturuNightmareDefinition _, CancellationToken __)
+ {
+ order.Add("subscribed");
+ return Task.FromResult(null);
+ }
+
+ Task Begin(KanturuPhaseDefinition _, CancellationToken __)
+ {
+ order.Add("begin");
+ return Task.CompletedTask;
+ }
+
+ var runner = new KanturuNightmareRunner(
+ Begin,
+ (_, _) => ValueTask.CompletedTask,
+ _ => ValueTask.CompletedTask,
+ () => ValueTask.CompletedTask,
+ (_, _) => Task.FromResult(true),
+ (_, _) => Task.CompletedTask,
+ WaitForSpawn,
+ _ => ValueTask.CompletedTask,
+ (_, _) => Task.CompletedTask,
+ NullLogger.Instance);
+
+ Assert.That(runner.Kind, Is.EqualTo(KanturuPhaseKind.Nightmare));
+ var phase = new KanturuPhaseDefinition
+ {
+ Nightmare = new KanturuNightmareDefinition
+ {
+ HpPhases = new List(),
+ HealthCheckInterval = TimeSpan.Zero,
+ SpecialAttackInterval = TimeSpan.Zero,
+ },
+ };
+ await runner.RunAsync(phase, CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(order, Is.EqualTo(new[] { "subscribed", "begin" }));
+ }
+
+ ///
+ /// Tests that the phase completes once the boss health drops below its threshold.
+ ///
+ [Test]
+ public async Task NightmareRunner_CompletesPhase_WithSummon()
+ {
+ var result = await this.RunSummonScenarioAsync().ConfigureAwait(false);
+
+ Assert.That(result.Completed, Is.True);
+ }
+
+ ///
+ /// Tests that the nightmare runner summons the configured wave once the
+ /// boss health drops below its threshold.
+ ///
+ [Test]
+ public async Task NightmareRunner_SummonsConfiguredWave_OnHealthPhaseThreshold()
+ {
+ var result = await this.RunSummonScenarioAsync().ConfigureAwait(false);
+
+ Assert.That(result.Waves, Is.EqualTo(new byte[] { 9 }));
+ }
+
+ ///
+ /// Tests that the live monster count is refreshed after the summon: once at
+ /// phase start, once after the summon.
+ ///
+ [Test]
+ public async Task NightmareRunner_RefreshesLiveCount_AfterSummon()
+ {
+ var result = await this.RunSummonScenarioAsync().ConfigureAwait(false);
+
+ Assert.That(result.LiveCountShows, Is.EqualTo(2));
+ }
+
+ ///
+ /// Tests that the phase completes without a configured summon wave.
+ ///
+ [Test]
+ public async Task NightmareRunner_CompletesPhase_WithoutSummonWave()
+ {
+ var result = await this.RunNoSummonScenarioAsync().ConfigureAwait(false);
+
+ Assert.That(result.Completed, Is.True);
+ }
+
+ ///
+ /// Tests that the teleport still happened without a summon wave: start message
+ /// plus teleport message prove it.
+ ///
+ [Test]
+ public async Task NightmareRunner_Teleports_WithoutSummonWave()
+ {
+ var result = await this.RunNoSummonScenarioAsync().ConfigureAwait(false);
+
+ Assert.That(result.Messages, Is.EqualTo(2));
+ }
+
+ ///
+ /// Tests that no wave spawns without a configured summon wave number.
+ ///
+ [Test]
+ public async Task NightmareRunner_SpawnsNoWave_WithoutWaveNumber()
+ {
+ var result = await this.RunNoSummonScenarioAsync().ConfigureAwait(false);
+
+ Assert.That(result.Waves, Is.Empty);
+ }
+
+ private async Task<(bool Completed, List Waves, int LiveCountShows)> RunSummonScenarioAsync()
+ {
+ var monster = CreateNightmare(this._map, this._gameContext);
+ monster.Health = 700; // 70% of 1000, below the 75% threshold.
+
+ var phase = new KanturuPhaseDefinition
+ {
+ Nightmare = new KanturuNightmareDefinition
+ {
+ HpPhases =
+ [
+ new KanturuNightmareHpPhase
+ {
+ HealthPercentage = 75,
+ TeleportTargetX = 100,
+ TeleportTargetY = 100,
+ SummonWaveNumber = 9,
+ },
+ ],
+ HealthCheckInterval = TimeSpan.FromMilliseconds(10),
+ TeleportDelay = TimeSpan.Zero,
+ SpecialAttackInterval = TimeSpan.Zero,
+ },
+ };
+
+ var waves = new List();
+ var summoned = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ Task SpawnWave(byte wave, CancellationToken _)
+ {
+ waves.Add(wave);
+ summoned.TrySetResult();
+ return Task.CompletedTask;
+ }
+
+ var liveCountShows = 0;
+ async Task WaitForPhaseEnd(KanturuPhaseDefinition _, CancellationToken ct)
+ {
+ await summoned.Task.WaitAsync(ct).ConfigureAwait(false);
+ return true;
+ }
+
+ var runner = new KanturuNightmareRunner(
+ (_, _) => Task.CompletedTask,
+ (_, _) => ValueTask.CompletedTask,
+ _ => ValueTask.CompletedTask,
+ () =>
+ {
+ liveCountShows++;
+ return ValueTask.CompletedTask;
+ },
+ WaitForPhaseEnd,
+ (_, _) => Task.CompletedTask,
+ (_, _) => Task.FromResult(monster),
+ _ => ValueTask.CompletedTask,
+ SpawnWave,
+ NullLogger.Instance);
+
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var completed = await runner.RunAsync(phase, timeout.Token).ConfigureAwait(false);
+ return (completed, waves, liveCountShows);
+ }
+
+ private async Task<(bool Completed, int Messages, List Waves)> RunNoSummonScenarioAsync()
+ {
+ var monster = CreateNightmare(this._map, this._gameContext);
+ monster.Health = 700; // 70% of 1000, below the 75% threshold.
+
+ var phase = new KanturuPhaseDefinition
+ {
+ Nightmare = new KanturuNightmareDefinition
+ {
+ HpPhases =
+ [
+ new KanturuNightmareHpPhase
+ {
+ HealthPercentage = 75,
+ TeleportTargetX = 100,
+ TeleportTargetY = 100,
+ },
+ ],
+ HealthCheckInterval = TimeSpan.FromMilliseconds(10),
+ TeleportDelay = TimeSpan.Zero,
+ SpecialAttackInterval = TimeSpan.Zero,
+ },
+ };
+
+ var messages = 0;
+ var waves = new List();
+ async Task WaitForPhaseEnd(KanturuPhaseDefinition _, CancellationToken ct)
+ {
+ await Task.Delay(500, ct).ConfigureAwait(false);
+ return true;
+ }
+
+ var runner = new KanturuNightmareRunner(
+ (_, _) => Task.CompletedTask,
+ (_, _) => ValueTask.CompletedTask,
+ _ =>
+ {
+ messages++;
+ return ValueTask.CompletedTask;
+ },
+ () => ValueTask.CompletedTask,
+ WaitForPhaseEnd,
+ (_, _) => Task.CompletedTask,
+ (_, _) => Task.FromResult(monster),
+ _ => ValueTask.CompletedTask,
+ (byte wave, CancellationToken _) =>
+ {
+ waves.Add(wave);
+ return Task.CompletedTask;
+ },
+ NullLogger.Instance);
+
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var completed = await runner.RunAsync(phase, timeout.Token).ConfigureAwait(false);
+ return (completed, messages, waves);
+ }
+
+ private static Monster CreateNightmare(GameMap map, IGameContext gameContext)
+ {
+ var definition = new MonsterDefinition { ObjectKind = NpcObjectKind.Monster };
+ definition.Attributes.Add(new MonsterAttribute { AttributeDefinition = Stats.MaximumHealth, Value = 1000 });
+ var spawnArea = new MonsterSpawnArea
+ {
+ MonsterDefinition = definition,
+ X1 = 100,
+ Y1 = 100,
+ X2 = 100,
+ Y2 = 100,
+ Quantity = 1,
+ };
+ var monster = new Monster(
+ spawnArea,
+ definition,
+ map,
+ NullDropGenerator.Instance,
+ new Mock().Object,
+ gameContext.PlugInManager,
+ gameContext.PathFinderPool);
+ monster.Initialize();
+ return monster;
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/KanturuRequiredItemHelperTests.cs b/tests/MUnique.OpenMU.Tests/KanturuRequiredItemHelperTests.cs
new file mode 100644
index 000000000..6d049802e
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/KanturuRequiredItemHelperTests.cs
@@ -0,0 +1,73 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using Moq;
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class KanturuRequiredItemHelperTests
+{
+ private static Item CreateItemWithPowerUp(AttributeDefinition targetAttribute)
+ {
+ var definitionMock = new Mock();
+ definitionMock.SetupAllProperties();
+ definitionMock.Setup(d => d.BasePowerUpAttributes).Returns(new List
+ {
+ new() { TargetAttribute = targetAttribute },
+ });
+
+ var itemMock = new Mock
- ();
+ itemMock.SetupAllProperties();
+ itemMock.Setup(i => i.Definition).Returns(definitionMock.Object);
+ return itemMock.Object;
+ }
+
+ ///
+ /// Tests that an item with a matching power-up attribute is returned.
+ ///
+ [Test]
+ public void GetRequiredItems_MatchingPowerUp_ReturnsItem()
+ {
+ var items = new[] { CreateItemWithPowerUp(Stats.CanFly) };
+ var requirements = new List { new() { Attribute = Stats.CanFly } };
+
+ var result = KanturuRequiredItemHelper.GetRequiredItems(items, requirements);
+
+ Assert.That(result, Has.Count.EqualTo(1));
+ }
+
+ ///
+ /// Tests that items without a matching power-up attribute are filtered out.
+ ///
+ [Test]
+ public void GetRequiredItems_OtherPowerUp_ReturnsEmpty()
+ {
+ var items = new[] { CreateItemWithPowerUp(Stats.CanFly) };
+ var requirements = new List { new() { Attribute = Stats.MaximumHealth } };
+
+ var result = KanturuRequiredItemHelper.GetRequiredItems(items, requirements);
+
+ Assert.That(result, Is.Empty);
+ }
+
+ ///
+ /// Tests that null items return an empty result.
+ ///
+ [Test]
+ public void GetRequiredItems_NullItems_ReturnsEmpty()
+ {
+ var requirements = new List { new() { Attribute = Stats.CanFly } };
+
+ Assert.That(KanturuRequiredItemHelper.GetRequiredItems((IEnumerable
- ?)null, requirements), Is.Empty);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/KanturuTowerTests.cs b/tests/MUnique.OpenMU.Tests/KanturuTowerTests.cs
new file mode 100644
index 000000000..f4c88dd35
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/KanturuTowerTests.cs
@@ -0,0 +1,174 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.MiniGames;
+using MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
+using MUnique.OpenMU.Pathfinding;
+using MUnique.OpenMU.Persistence.InMemory;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Tests for the persistent Tower of Refinement window.
+///
+[TestFixture]
+public class KanturuTowerTests
+{
+ ///
+ /// Tests that the tower window survives a plug-in configuration round trip.
+ ///
+ [Test]
+ public void TowerConfiguration_RoundTripsThroughPlugInConfiguration()
+ {
+ var configuration = new PlugInConfiguration();
+ var startConfiguration = new KanturuStartConfiguration
+ {
+ TowerOpenDuration = TimeSpan.FromHours(12),
+ TowerOpenUntilUtc = new DateTime(2026, 9, 23, 12, 0, 0, DateTimeKind.Utc),
+ };
+
+ configuration.SetConfiguration(startConfiguration, null);
+ var restored = configuration.GetConfiguration(null);
+
+ Assert.That(restored, Is.Not.Null);
+ Assert.That(restored!.TowerOpenDuration, Is.EqualTo(TimeSpan.FromHours(12)));
+ Assert.That(restored.TowerOpenUntilUtc, Is.EqualTo(startConfiguration.TowerOpenUntilUtc));
+ }
+
+ ///
+ /// Tests that the tower definition keys identically to the event definition,
+ /// but carries the remaining window as its game duration.
+ ///
+ [Test]
+ public void CreateTowerDefinition_KeysLikeEventDefinition_WithTowerTimers()
+ {
+ var source = new MiniGameDefinition
+ {
+ Type = MiniGameType.Kanturu,
+ Name = "Kanturu Refinery Tower",
+ GameLevel = 1,
+ MapCreationPolicy = MiniGameMapCreationPolicy.Shared,
+ Entrance = new ExitGate { Map = new GameMapDefinition { Number = 39 } },
+ MaximumPlayerCount = 10,
+ AllowParty = true,
+ GameDuration = TimeSpan.FromMinutes(135),
+ };
+ var remaining = TimeSpan.FromHours(11);
+
+ var tower = KanturuTowerEntry.CreateTowerDefinition(source, remaining);
+
+ Assert.That(MiniGameMapKey.Create(tower, null!), Is.EqualTo(MiniGameMapKey.Create(source, null!)));
+ Assert.That(tower.GameDuration, Is.EqualTo(remaining));
+ Assert.That(tower.EnterDuration, Is.EqualTo(TimeSpan.Zero));
+ Assert.That(tower.ExitDuration, Is.EqualTo(TimeSpan.Zero));
+ Assert.That(tower.Rewards, Is.Empty);
+ Assert.That(tower.SpawnWaves, Is.Empty);
+ Assert.That(tower.ChangeEvents, Is.Empty);
+ }
+
+ ///
+ /// Tests that tower entry doesn't apply without a stored window.
+ ///
+ [Test]
+ public void GetRemainingTowerWindow_WithoutStoredWindow_ReturnsNull()
+ {
+ var player = CreatePlayer();
+
+ var remaining = KanturuTowerEntry.GetRemainingTowerWindow(player, new MiniGameDefinition());
+
+ Assert.That(remaining, Is.Null);
+ }
+
+ ///
+ /// Tests that no tower game is ensured without a stored window.
+ ///
+ [Test]
+ public async Task EnsureTowerGameAsync_WithoutStoredWindow_ReturnsFalse()
+ {
+ var player = CreatePlayer();
+
+ var ensured = await KanturuTowerEntry.EnsureTowerGameAsync(player, new MiniGameDefinition()).ConfigureAwait(false);
+
+ Assert.That(ensured, Is.False);
+ }
+
+ ///
+ /// Tests that the tower entry point comes from the configured transition.
+ ///
+ [Test]
+ public void GetTowerEntryPoint_WithTransition_ReturnsEntryPoint()
+ {
+ var definition = new KanturuEventDefinition
+ {
+ Phases = new List
+ {
+ new()
+ {
+ Kind = KanturuPhaseKind.Transition,
+ Transition = new KanturuTransitionDefinition { EntryPointX = 79, EntryPointY = 98 },
+ },
+ },
+ };
+
+ var entryPoint = KanturuTowerEntry.GetTowerEntryPoint(definition);
+
+ Assert.That(entryPoint, Is.EqualTo(new Point(79, 98)));
+ }
+
+ ///
+ /// Tests that no entry point is returned without a configured transition.
+ ///
+ [Test]
+ public void GetTowerEntryPoint_WithoutTransition_ReturnsNull()
+ {
+ Assert.That(KanturuTowerEntry.GetTowerEntryPoint(new KanturuEventDefinition()), Is.Null);
+ }
+
+ ///
+ /// Tests that disposing the running games (game master restart) clears the tower window,
+ /// so the forced start below isn't blocked by it.
+ ///
+ [Test]
+ public async Task DisposeRunningGamesAsync_ClearsTowerWindow()
+ {
+ var manager = new PlugInManager(null, NullLoggerFactory.Instance, null, null);
+ var startPlugIn = new KanturuStartPlugIn
+ {
+ Configuration = new KanturuStartConfiguration { TowerOpenUntilUtc = DateTime.UtcNow.AddHours(1) },
+ };
+ manager.RegisterPlugInAtPlugInPoint(startPlugIn);
+
+ var miniGamesMock = new Mock();
+ miniGamesMock
+ .Setup(m => m.GetRunningMiniGames(It.IsAny()))
+ .Returns(new List());
+
+ var contextMock = new Mock();
+ contextMock.SetupGet(c => c.LoggerFactory).Returns(NullLoggerFactory.Instance);
+ contextMock.SetupGet(c => c.Configuration).Returns(new GameConfiguration());
+ contextMock.SetupGet(c => c.PersistenceContextProvider).Returns(new InMemoryPersistenceContextProvider());
+ contextMock.SetupGet(c => c.PlugInManager).Returns(manager);
+ contextMock.SetupGet(c => c.MiniGames).Returns(miniGamesMock.Object);
+
+ await startPlugIn.DisposeRunningGamesAsync(contextMock.Object).ConfigureAwait(false);
+
+ Assert.That(startPlugIn.Configuration!.TowerOpenUntilUtc, Is.Null);
+ }
+
+ private static Player CreatePlayer()
+ {
+ var contextMock = new Mock();
+ contextMock.SetupGet(c => c.LoggerFactory).Returns(NullLoggerFactory.Instance);
+ contextMock.SetupGet(c => c.Configuration).Returns(new GameConfiguration());
+ contextMock.SetupGet(c => c.PersistenceContextProvider).Returns(new InMemoryPersistenceContextProvider());
+ contextMock.SetupGet(c => c.PlugInManager).Returns(new PlugInManager(null, NullLoggerFactory.Instance, null, null));
+ return new Player(contextMock.Object);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/KanturuWaveTimerTests.cs b/tests/MUnique.OpenMU.Tests/KanturuWaveTimerTests.cs
new file mode 100644
index 000000000..42c64a71c
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/KanturuWaveTimerTests.cs
@@ -0,0 +1,99 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using MUnique.OpenMU.GameLogic.MiniGames.Kanturu;
+
+///
+/// Tests for the shared wave countdown.
+///
+[TestFixture]
+public class KanturuWaveTimerTests
+{
+ ///
+ /// Tests that a phase without a group simply keeps its own limit.
+ ///
+ [Test]
+ public void UngroupedPhase_KeepsOwnLimit()
+ {
+ var timer = new KanturuWaveTimer(() => DateTime.UtcNow);
+ var phase = new KanturuPhaseDefinition { TimeLimit = TimeSpan.FromMinutes(15) };
+
+ Assert.That(timer.GetEffectiveLimit(phase), Is.EqualTo(TimeSpan.FromMinutes(15)));
+ }
+
+ ///
+ /// Tests that a phase without a group and without a limit has no countdown.
+ ///
+ [Test]
+ public void UngroupedPhaseWithoutLimit_HasNoCountdown()
+ {
+ var timer = new KanturuWaveTimer(() => DateTime.UtcNow);
+
+ Assert.That(timer.GetEffectiveLimit(new KanturuPhaseDefinition()), Is.Null);
+ }
+
+ ///
+ /// Tests that the first grouped phase starts the shared clock with its full limit,
+ /// and a following phase of the group inherits the remainder.
+ ///
+ [Test]
+ public void GroupedPhases_ShareOneClock()
+ {
+ var now = new DateTime(2026, 9, 26, 12, 0, 0, DateTimeKind.Utc);
+ var timer = new KanturuWaveTimer(() => now);
+ var monsters = new KanturuPhaseDefinition { TimeLimit = TimeSpan.FromMinutes(15), TimeLimitGroup = KanturuWaveGroup.MayaLeftHand };
+ var boss = new KanturuPhaseDefinition { TimeLimitGroup = KanturuWaveGroup.MayaLeftHand };
+
+ Assert.That(timer.GetEffectiveLimit(monsters), Is.EqualTo(TimeSpan.FromMinutes(15)));
+
+ now += TimeSpan.FromMinutes(14);
+ Assert.That(timer.GetEffectiveLimit(boss), Is.EqualTo(TimeSpan.FromMinutes(1)));
+ }
+
+ ///
+ /// Tests that a following phase sees an expired remainder once the shared clock ran out.
+ ///
+ [Test]
+ public void GroupedPhase_AfterExpiry_SeesExpiredRemainder()
+ {
+ var now = new DateTime(2026, 9, 26, 12, 0, 0, DateTimeKind.Utc);
+ var timer = new KanturuWaveTimer(() => now);
+ var monsters = new KanturuPhaseDefinition { TimeLimit = TimeSpan.FromMinutes(15), TimeLimitGroup = KanturuWaveGroup.MayaLeftHand };
+ var boss = new KanturuPhaseDefinition { TimeLimitGroup = KanturuWaveGroup.MayaLeftHand };
+
+ timer.GetEffectiveLimit(monsters);
+ now += TimeSpan.FromMinutes(16);
+
+ Assert.That(timer.GetEffectiveLimit(boss), Is.LessThanOrEqualTo(TimeSpan.Zero));
+ }
+
+ ///
+ /// Tests that different groups run independent clocks.
+ ///
+ [Test]
+ public void DifferentGroups_RunIndependentClocks()
+ {
+ var now = new DateTime(2026, 9, 26, 12, 0, 0, DateTimeKind.Utc);
+ var timer = new KanturuWaveTimer(() => now);
+
+ timer.GetEffectiveLimit(new KanturuPhaseDefinition { TimeLimit = TimeSpan.FromMinutes(15), TimeLimitGroup = KanturuWaveGroup.MayaLeftHand });
+ now += TimeSpan.FromMinutes(14);
+
+ var nextWave = new KanturuPhaseDefinition { TimeLimit = TimeSpan.FromMinutes(15), TimeLimitGroup = KanturuWaveGroup.MayaRightHand };
+ Assert.That(timer.GetEffectiveLimit(nextWave), Is.EqualTo(TimeSpan.FromMinutes(15)));
+ }
+
+ ///
+ /// Tests that a grouped phase without any clock started in its group has no countdown.
+ ///
+ [Test]
+ public void GroupedPhaseWithoutStartedClock_HasNoCountdown()
+ {
+ var timer = new KanturuWaveTimer(() => DateTime.UtcNow);
+
+ Assert.That(timer.GetEffectiveLimit(new KanturuPhaseDefinition { TimeLimitGroup = KanturuWaveGroup.MayaLeftHand }), Is.Null);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/MiniGamePlayerRegistryTests.cs b/tests/MUnique.OpenMU.Tests/MiniGamePlayerRegistryTests.cs
index 095e7599f..a3d13f316 100644
--- a/tests/MUnique.OpenMU.Tests/MiniGamePlayerRegistryTests.cs
+++ b/tests/MUnique.OpenMU.Tests/MiniGamePlayerRegistryTests.cs
@@ -60,6 +60,49 @@ public async Task EnterWhenFullIsRejectedAsync()
Assert.That(result, Is.EqualTo(EnterResult.Full));
}
+ ///
+ /// Tests that entering while the game runs is rejected by default.
+ ///
+ [Test]
+ public async Task EnterWhilePlayingIsRejectedByDefaultAsync()
+ {
+ var registry = new MiniGamePlayerRegistry(this.CreateDefinition());
+ await registry.SetStateAsync(MiniGameState.Playing).ConfigureAwait(false);
+
+ var result = await registry.TryEnterAsync(CreatePlayer(), _ => ValueTask.FromResult(true)).ConfigureAwait(false);
+
+ Assert.That(result, Is.EqualTo(EnterResult.NotOpen));
+ }
+
+ ///
+ /// Tests that entering while the game runs succeeds when late entering is allowed,
+ /// e.g. to rejoin an ongoing event.
+ ///
+ [Test]
+ public async Task EnterWhilePlayingWithFlagSucceedsAsync()
+ {
+ var registry = new MiniGamePlayerRegistry(this.CreateDefinition());
+ await registry.SetStateAsync(MiniGameState.Playing).ConfigureAwait(false);
+
+ var result = await registry.TryEnterAsync(CreatePlayer(), _ => ValueTask.FromResult(true), allowEnterWhilePlaying: () => true).ConfigureAwait(false);
+
+ Assert.That(result, Is.EqualTo(EnterResult.Success));
+ }
+
+ ///
+ /// Tests that the late entering flag doesn't open ended games.
+ ///
+ [Test]
+ public async Task EnterWhenEndedWithFlagIsRejectedAsync()
+ {
+ var registry = new MiniGamePlayerRegistry(this.CreateDefinition());
+ await registry.SetStateAsync(MiniGameState.Ended).ConfigureAwait(false);
+
+ var result = await registry.TryEnterAsync(CreatePlayer(), _ => ValueTask.FromResult(true), allowEnterWhilePlaying: () => true).ConfigureAwait(false);
+
+ Assert.That(result, Is.EqualTo(EnterResult.NotOpen));
+ }
+
private static Player CreatePlayer()
{
var contextMock = new Mock();
diff --git a/tests/MUnique.OpenMU.Tests/MiniGameStartPlugInTests.cs b/tests/MUnique.OpenMU.Tests/MiniGameStartPlugInTests.cs
index 4b5fd8a3f..d94ea0e93 100644
--- a/tests/MUnique.OpenMU.Tests/MiniGameStartPlugInTests.cs
+++ b/tests/MUnique.OpenMU.Tests/MiniGameStartPlugInTests.cs
@@ -387,6 +387,10 @@ private MiniGameContext CreateGame(
definition,
gameContextMock.Object,
mapInitializerMock.Object);
+
+ // Mirrors MiniGameManager.GetOrCreateAsync, which starts the loop after
+ // construction so overridden members read post-construction values.
+ game.EnsureGameLoopRunning();
this._gamesToDispose.Add(game);
return game;
}