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.
This commit is contained in:
Acentech Dev
2026-07-21 15:40:45 +03:00
parent bd5cda12dd
commit a2119b272e
3 changed files with 142 additions and 14 deletions

View File

@@ -87,16 +87,39 @@ public class HeykelSavasiContext : MiniGameContext
private const short GuardMonsterNumber = 580;
/// <summary>
/// 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 <see cref="IsStatueAttackBlocked"/>).
/// 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 <see cref="SpawnStatueAsync"/>.
/// </summary>
private static readonly (int Dx, int Dy)[] GuardOffsets = { (-2, 0), (2, 0), (0, 2) };
private const int GuardSpawnRadius = 2;
/// <summary>
/// The number of guard mobs which spawn next to (and protect) each statue.
/// </summary>
private const int GuardsPerStatue = 3;
/// <summary>
/// The total number of statues (both teams combined) that must be broken before the 4 arena bosses
/// spawn (see <see cref="SpawnBossesAsync"/>). Requested by design: "these bosses spawn when a total
/// of 3 statues have been broken".
/// </summary>
private const int BossSpawnAtTotalDestroyed = 3;
/// <summary>
/// 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 <see cref="BossSpawnAtTotalDestroyed"/> statues have
/// fallen: 306=Death Rider, 309=Hell Maine, 357=Genocider, 459=Selupan.
/// </summary>
private static readonly (short Number, byte X, byte Y)[] BossSpawns =
{
(306, 55, 42),
(309, 32, 42),
(357, 31, 59),
(459, 56, 58),
};
/// <summary>
/// Maps a destroyed statue's index (0..5) to the <see cref="MUnique.OpenMU.DataModel.Configuration.MagicEffectDefinition.Number"/>
/// 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
/// </summary>
private readonly StatueProgressState _progress = new();
/// <summary>
/// Guards the one-shot arena boss spawn (see <see cref="SpawnBossesAsync"/>): set to 1 the first time
/// <see cref="BossSpawnAtTotalDestroyed"/> statues have fallen, so the 4 bosses spawn exactly once.
/// </summary>
private int _bossesSpawned;
/// <summary>
/// Synchronizes the check-then-act sequence of <see cref="TryJoinTeamAsync"/>, 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<MUnique.OpenMU.GameLogic.Views.IShowMessagePlugIn>(
@@ -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
}
}
/// <summary>
/// Spawns the 4 arena bosses (see <see cref="BossSpawns"/>) at their fixed coordinates. Triggered once,
/// from <see cref="OnStatueDestroyedSideEffectsAsync"/>, after <see cref="BossSpawnAtTotalDestroyed"/>
/// 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).
/// </summary>
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<MUnique.OpenMU.GameLogic.Views.IShowMessagePlugIn>(
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);
}
}
/// <summary>
/// Applies the class buff mapped to <paramref name="statueIndex"/> (see <see cref="StatueBuffEffectNumbers"/>)
/// to every player currently on <paramref name="team"/>. A no-op for the 7th statue (index 6, out of
@@ -1084,6 +1179,12 @@ public class HeykelSavasiContext : MiniGameContext
/// <param name="attacker">The attacking team.</param>
public int GetProgress(HeykelSavasiTeam attacker) => this._progress.TryGetValue(attacker, out var value) ? value : 0;
/// <summary>
/// Gets the total number of statues destroyed so far by both teams combined (used to trigger the
/// arena boss spawn once <see cref="BossSpawnAtTotalDestroyed"/> is reached).
/// </summary>
public int TotalDestroyed => this.GetProgress(HeykelSavasiTeam.Red) + this.GetProgress(HeykelSavasiTeam.Blue);
/// <summary>
/// Gets the winning team, or <see cref="HeykelSavasiTeam.None"/> if undecided.
/// </summary>

View File

@@ -29,7 +29,7 @@ public class HeykelSavasiJoinAction
else
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(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);
}
}
}

View File

@@ -30,4 +30,31 @@ public sealed class HeykelSavasiStartPlugIn : MiniGameStartBasePlugIn<HeykelSava
{
return new HeykelSavasiGameServerState(gameContext);
}
/// <inheritdoc />
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);
}
}