From a2119b272e2c20c6eb6d0d260fbe481a29c5d2f6 Mon Sep 17 00:00:00 2001 From: Acentech Dev Date: Tue, 21 Jul 2026 15:40:45 +0300 Subject: [PATCH] feat(tvt): 4 arena bosses at 3 statues, robust guard/boss spawn, reliable GM restart, English strings - Bosses: 306/309/357/459 spawn once at (55,42)/(32,42)/(31,59)/(56,58) after 3 total statues broken. - Guards/bosses spawn in a walkable BOX (retry) instead of a fixed point; several statue-guard offsets landed on non-walkable terrain and silently failed, leaving statues unbreakable. - Guard-alive gate uses the ACTUAL spawned count so a statue can always reach 0 -> breakable. - HeykelSavasiStartPlugIn.OnStartedAsync force-disposes a lingering finished context so GM /starths restarts the event immediately (no server restart). - Statue-break + join messages translated to English. --- .../MiniGames/HeykelSavasiContext.cs | 127 ++++++++++++++++-- .../MiniGames/HeykelSavasiJoinAction.cs | 2 +- .../PeriodicTasks/HeykelSavasiStartPlugIn.cs | 27 ++++ 3 files changed, 142 insertions(+), 14 deletions(-) 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); + } }