diff --git a/src/GameLogic/MiniGames/HeykelSavasiContext.cs b/src/GameLogic/MiniGames/HeykelSavasiContext.cs
index 3c3789d..d091d64 100644
--- a/src/GameLogic/MiniGames/HeykelSavasiContext.cs
+++ b/src/GameLogic/MiniGames/HeykelSavasiContext.cs
@@ -87,16 +87,39 @@ public class HeykelSavasiContext : MiniGameContext
private const short GuardMonsterNumber = 580;
///
- /// The (dx, dy) offsets, relative to a statue's position, at which its 3 guards spawn. A statue
- /// cannot be damaged until all 3 of these guards are dead (see ).
+ /// The radius (in tiles) of the box around a statue in which its guards spawn. A BOX (rather than fixed
+ /// offsets) lets the spawn logic retry to find a WALKABLE tile: several statues sit next to
+ /// non-walkable terrain (water/arena edges) where a fixed offset would land off-map, the guard would
+ /// silently fail to spawn, and the statue would stay permanently unbreakable (its guard-alive count
+ /// could never reach 0). See .
///
- private static readonly (int Dx, int Dy)[] GuardOffsets = { (-2, 0), (2, 0), (0, 2) };
+ private const int GuardSpawnRadius = 2;
///
/// The number of guard mobs which spawn next to (and protect) each statue.
///
private const int GuardsPerStatue = 3;
+ ///
+ /// The total number of statues (both teams combined) that must be broken before the 4 arena bosses
+ /// spawn (see ). Requested by design: "these bosses spawn when a total
+ /// of 3 statues have been broken".
+ ///
+ private const int BossSpawnAtTotalDestroyed = 3;
+
+ ///
+ /// The 4 arena bosses (existing S6 boss monster numbers, so the client already has their models) and
+ /// the fixed coordinates at which they spawn once statues have
+ /// fallen: 306=Death Rider, 309=Hell Maine, 357=Genocider, 459=Selupan.
+ ///
+ private static readonly (short Number, byte X, byte Y)[] BossSpawns =
+ {
+ (306, 55, 42),
+ (309, 32, 42),
+ (357, 31, 59),
+ (459, 56, 58),
+ };
+
///
/// Maps a destroyed statue's index (0..5) to the
/// of the class buff granted to the whole attacking team. All of these effects already exist in
@@ -204,6 +227,12 @@ public class HeykelSavasiContext : MiniGameContext
///
private readonly StatueProgressState _progress = new();
+ ///
+ /// Guards the one-shot arena boss spawn (see ): set to 1 the first time
+ /// statues have fallen, so the 4 bosses spawn exactly once.
+ ///
+ private int _bossesSpawned;
+
///
/// Synchronizes the check-then-act sequence of , so that concurrent
/// join attempts cannot both pass the balance check before either of them reserves a slot.
@@ -682,7 +711,7 @@ public class HeykelSavasiContext : MiniGameContext
await this.BroadcastHudStateAsync().ConfigureAwait(false);
// Yellow center announcement to every event player: running counter + the destroyer's name.
- var message = $"{count} Heykel {killerName} tarafindan yok edildi";
+ var message = $"Statue {count} destroyed by {killerName}!";
foreach (var player in this.PlayersOf(HeykelSavasiTeam.Red).Concat(this.PlayersOf(HeykelSavasiTeam.Blue)).ToList())
{
await player.InvokeViewPlugInAsync(
@@ -692,6 +721,14 @@ public class HeykelSavasiContext : MiniGameContext
// Count-based buff: 1st break -> buff index 0, ... 6th -> index 5, 7th -> none (bounds guard).
await this.ApplyStatueBuffToTeamAsync(attacker, count - 1).ConfigureAwait(false);
+ // Once BossSpawnAtTotalDestroyed statues (both teams combined) have fallen, spawn the 4 arena
+ // bosses exactly once. Interlocked guards against two near-simultaneous breaks both triggering.
+ if (this._progress.TotalDestroyed >= BossSpawnAtTotalDestroyed
+ && Interlocked.CompareExchange(ref this._bossesSpawned, 1, 0) == 0)
+ {
+ await this.SpawnBossesAsync().ConfigureAwait(false);
+ }
+
if (this.GetWinner() == attacker)
{
this.FinishEvent();
@@ -761,9 +798,6 @@ public class HeykelSavasiContext : MiniGameContext
this._statues[statue] = (defender, index);
- // This statue starts fully guarded; it becomes damageable only once all 3 guards are dead.
- this._statueGuardsAlive[statue] = GuardsPerStatue;
-
var guardDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GuardMonsterNumber);
if (guardDefinition is null)
{
@@ -771,17 +805,20 @@ public class HeykelSavasiContext : MiniGameContext
return;
}
- for (var i = 0; i < GuardOffsets.Length; i++)
+ var spawnedGuards = 0;
+ for (var i = 0; i < GuardsPerStatue; i++)
{
- var (dx, dy) = GuardOffsets[i];
+ // Guards spawn on a WALKABLE tile in a small box around the statue. A box (not a fixed point)
+ // lets InitializeSpawnAsync retry to find a walkable tile near statues that sit against
+ // non-walkable terrain (see GuardSpawnRadius).
var guardSpawnArea = new MonsterSpawnArea
{
MonsterDefinition = guardDefinition,
Quantity = 1,
- X1 = (byte)(pos.X + dx),
- X2 = (byte)(pos.X + dx),
- Y1 = (byte)(pos.Y + dy),
- Y2 = (byte)(pos.Y + dy),
+ X1 = (byte)Math.Clamp(pos.X - GuardSpawnRadius, 1, 254),
+ X2 = (byte)Math.Clamp(pos.X + GuardSpawnRadius, 1, 254),
+ Y1 = (byte)Math.Clamp(pos.Y - GuardSpawnRadius, 1, 254),
+ Y2 = (byte)Math.Clamp(pos.Y + GuardSpawnRadius, 1, 254),
Direction = Direction.South,
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
};
@@ -793,8 +830,14 @@ public class HeykelSavasiContext : MiniGameContext
if (guardNpc is AttackableNpcBase guard)
{
this._guardToStatue[guard] = statue;
+ spawnedGuards++;
}
}
+
+ // The statue becomes damageable only once all of its ACTUALLY-SPAWNED guards are dead. Using the
+ // real spawned count (not the constant) guarantees the gate can always reach 0 even if a guard
+ // still failed to spawn - otherwise that statue would be permanently unbreakable.
+ this._statueGuardsAlive[statue] = spawnedGuards;
}
catch (Exception ex)
{
@@ -802,6 +845,58 @@ public class HeykelSavasiContext : MiniGameContext
}
}
+ ///
+ /// Spawns the 4 arena bosses (see ) at their fixed coordinates. Triggered once,
+ /// from , after
+ /// statues (both teams combined) have been broken. Each boss spawns on a walkable tile in a small box
+ /// around its coordinate (same retry-tolerant approach as the guards). Drops are the bosses' own default
+ /// loot for now (to be tuned later).
+ ///
+ private async ValueTask SpawnBossesAsync()
+ {
+ try
+ {
+ // Broadcast the boss arrival to every event player (yellow center message).
+ const string bossMessage = "The arena bosses have appeared!";
+ foreach (var player in this.PlayersOf(HeykelSavasiTeam.Red).Concat(this.PlayersOf(HeykelSavasiTeam.Blue)).ToList())
+ {
+ await player.InvokeViewPlugInAsync(
+ p => p.ShowMessageAsync(bossMessage, MUnique.OpenMU.Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false);
+ }
+
+ for (var i = 0; i < BossSpawns.Length; i++)
+ {
+ var (number, x, y) = BossSpawns[i];
+ var bossDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == number);
+ if (bossDefinition is null)
+ {
+ this.Logger.LogWarning("{context}: Boss monster definition {number} not found.", this, number);
+ continue;
+ }
+
+ // Bosses are Monsters -> need a walkable tile; use a small box so the spawn logic can retry.
+ var bossSpawnArea = new MonsterSpawnArea
+ {
+ MonsterDefinition = bossDefinition,
+ Quantity = 1,
+ X1 = (byte)Math.Clamp(x - GuardSpawnRadius, 1, 254),
+ X2 = (byte)Math.Clamp(x + GuardSpawnRadius, 1, 254),
+ Y1 = (byte)Math.Clamp(y - GuardSpawnRadius, 1, 254),
+ Y2 = (byte)Math.Clamp(y + GuardSpawnRadius, 1, 254),
+ Direction = Direction.South,
+ SpawnTrigger = SpawnTrigger.OnceAtEventStart,
+ };
+
+ // Distinct spawn-index range for bosses (9000+), so they never collide with statues/guards.
+ await this._mapInitializer.InitializeSpawnAsync(9000 + i, this.Map, bossSpawnArea, this).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Logger.LogError(ex, "{context}: Error spawning arena bosses.", this);
+ }
+ }
+
///
/// Applies the class buff mapped to (see )
/// to every player currently on . A no-op for the 7th statue (index 6, out of
@@ -1084,6 +1179,12 @@ public class HeykelSavasiContext : MiniGameContext
/// The attacking team.
public int GetProgress(HeykelSavasiTeam attacker) => this._progress.TryGetValue(attacker, out var value) ? value : 0;
+ ///
+ /// Gets the total number of statues destroyed so far by both teams combined (used to trigger the
+ /// arena boss spawn once is reached).
+ ///
+ public int TotalDestroyed => this.GetProgress(HeykelSavasiTeam.Red) + this.GetProgress(HeykelSavasiTeam.Blue);
+
///
/// Gets the winning team, or if undecided.
///
diff --git a/src/GameLogic/PlayerActions/MiniGames/HeykelSavasiJoinAction.cs b/src/GameLogic/PlayerActions/MiniGames/HeykelSavasiJoinAction.cs
index 62c2f0e..3a88b5c 100644
--- a/src/GameLogic/PlayerActions/MiniGames/HeykelSavasiJoinAction.cs
+++ b/src/GameLogic/PlayerActions/MiniGames/HeykelSavasiJoinAction.cs
@@ -29,7 +29,7 @@ public class HeykelSavasiJoinAction
else
{
await player.InvokeViewPlugInAsync(p =>
- p.ShowMessageAsync("Takima simdi katilamadin (denge/durum).", MessageType.BlueNormal)).ConfigureAwait(false);
+ p.ShowMessageAsync("You can't join a team right now (team balance/status).", MessageType.BlueNormal)).ConfigureAwait(false);
}
}
}
diff --git a/src/GameLogic/PlugIns/PeriodicTasks/HeykelSavasiStartPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/HeykelSavasiStartPlugIn.cs
index 389c13a..c5d75db 100644
--- a/src/GameLogic/PlugIns/PeriodicTasks/HeykelSavasiStartPlugIn.cs
+++ b/src/GameLogic/PlugIns/PeriodicTasks/HeykelSavasiStartPlugIn.cs
@@ -30,4 +30,31 @@ public sealed class HeykelSavasiStartPlugIn : MiniGameStartBasePlugIn
+ protected override async ValueTask OnStartedAsync(HeykelSavasiGameServerState state)
+ {
+ // A GM /starths must work even immediately after a previous run ended. When the event finishes, its
+ // MiniGameContext lingers in a terminal state (Ended/Closed) for its exit ceremony (~60s) BEFORE it
+ // disposes; during that window GetMiniGameAsync (which only skips disposed/disposing contexts) would
+ // return that stale, already-finished context instead of opening a fresh event - so nothing starts
+ // until a server restart clears it. Force-dispose any lingering non-running context here first, so the
+ // base implementation then creates a brand-new event. A genuinely running game (Open/Playing) is left
+ // untouched (the base call just reuses it - no double start).
+ var definitions = state.Context.Configuration.MiniGameDefinitions
+ .Where(d => d.Type == this.Key && d.MapCreationPolicy == MiniGameMapCreationPolicy.Shared)
+ .ToList();
+
+ foreach (var definition in definitions)
+ {
+ var existing = await state.Context.GetMiniGameAsync(definition, null!).ConfigureAwait(false);
+ if (existing is { IsDisposed: false, IsDisposing: false }
+ && existing.State is not MiniGameState.Open and not MiniGameState.Playing)
+ {
+ await existing.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ await base.OnStartedAsync(state).ConfigureAwait(false);
+ }
}