diff --git a/src/GameLogic/MiniGames/HeykelSavasiContext.cs b/src/GameLogic/MiniGames/HeykelSavasiContext.cs
index 8823b76..3c3789d 100644
--- a/src/GameLogic/MiniGames/HeykelSavasiContext.cs
+++ b/src/GameLogic/MiniGames/HeykelSavasiContext.cs
@@ -87,9 +87,15 @@ public class HeykelSavasiContext : MiniGameContext
private const short GuardMonsterNumber = 580;
///
- /// The (dx, dy) offsets, relative to a statue's position, at which its 4 guards spawn.
+ /// 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 ).
///
- private static readonly (int Dx, int Dy)[] GuardOffsets = { (-2, 0), (2, 0), (0, -2), (0, 2) };
+ private static readonly (int Dx, int Dy)[] GuardOffsets = { (-2, 0), (2, 0), (0, 2) };
+
+ ///
+ /// The number of guard mobs which spawn next to (and protect) each statue.
+ ///
+ private const int GuardsPerStatue = 3;
///
/// Maps a destroyed statue's index (0..5) to the
@@ -108,37 +114,35 @@ public class HeykelSavasiContext : MiniGameContext
private static readonly TimeSpan BuffDuration = TimeSpan.FromMinutes(6);
///
- /// PLACEHOLDER coordinates: the Heykel Savasi map terrain is not final yet, so these positions are an
- /// approximate straight line from mid-map toward the red base (20, 20). Index 0 is the outermost statue
- /// (attacked first by the blue team), index 6 is the final statue, closest to the red base (win condition
- /// for blue once broken).
+ /// The FIXED positions of the red team's 7 statues (attacked by the blue team). All 7 are spawned and
+ /// visible at battle start and can be broken in ANY order; the index (0..6) is retained only for the
+ /// per-break buff mapping (see ), not for spawn sequencing.
///
private static readonly Point[] RedStatuePositions =
{
- new(42, 51),
- new(42, 45),
- new(42, 38),
- new(42, 31),
- new(42, 25),
- new(42, 15),
- new(42, 8),
+ new(43, 20),
+ new(56, 26),
+ new(72, 43),
+ new(43, 28),
+ new(43, 44),
+ new(29, 26),
+ new(14, 43),
};
///
- /// PLACEHOLDER coordinates: the Heykel Savasi map terrain is not final yet, so these positions are an
- /// approximate straight line from mid-map toward the blue base (220, 220). Index 0 is the outermost statue
- /// (attacked first by the red team), index 6 is the final statue, closest to the blue base (win condition
- /// for red once broken).
+ /// The FIXED positions of the blue team's 7 statues (attacked by the red team). All 7 are spawned and
+ /// visible at battle start and can be broken in ANY order; the index (0..6) is retained only for the
+ /// per-break buff mapping (see ), not for spawn sequencing.
///
private static readonly Point[] BlueStatuePositions =
{
- new(42, 53),
- new(42, 59),
- new(42, 66),
- new(42, 73),
- new(42, 79),
- new(42, 87),
- new(42, 94),
+ new(16, 61),
+ new(31, 77),
+ new(43, 72),
+ new(43, 58),
+ new(72, 60),
+ new(57, 76),
+ new(43, 82),
};
///
@@ -180,6 +184,19 @@ public class HeykelSavasiContext : MiniGameContext
///
private readonly ConcurrentDictionary _statues = new();
+ ///
+ /// Maps each spawned statue NPC to the number of its guard mobs which are still alive. A statue starts at
+ /// and can only be damaged once this reaches 0 (see
+ /// ); decremented by as guards die.
+ ///
+ private readonly ConcurrentDictionary _statueGuardsAlive = new();
+
+ ///
+ /// Maps each spawned guard mob to the statue it protects, so can decrement the
+ /// right statue's living-guard count when a guard dies. The guard is removed once its death is processed.
+ ///
+ private readonly ConcurrentDictionary _guardToStatue = new();
+
///
/// Pure statue-break progress/winner state, extracted into its own dependency-free type so it can be
/// unit-tested without constructing a full (whose constructor needs a
@@ -247,12 +264,29 @@ public class HeykelSavasiContext : MiniGameContext
public HeykelSavasiTeam GetTeam(Player player) => this._teams.TryGetValue(player, out var team) ? team : HeykelSavasiTeam.None;
///
- /// Returns true if is a Heykel Savasi statue belonging to the
- /// same team as — such statues must be immune to friendly damage,
- /// otherwise a player could destroy their own team's statue and credit the enemy.
+ /// Returns true if a hit on by must be blocked.
+ /// A statue is protected (undamageable) while EITHER it belongs to the attacker's own team (a player may
+ /// never break their own team's statue and credit the enemy) OR any of its 3 guard mobs are still alive
+ /// (the guards must be cleared first). If is not a tracked statue, the hit is never
+ /// blocked here (normal damage). Called from AttackableNpcBase.AttackByAsync.
///
- public bool IsFriendlyStatue(AttackableNpcBase npc, Player attacker)
- => this._statues.TryGetValue(npc, out var info) && info.Defender == this.GetTeam(attacker);
+ /// The NPC being attacked.
+ /// The attacking player.
+ /// true if the hit should be blocked; otherwise, false.
+ public bool IsStatueAttackBlocked(AttackableNpcBase npc, Player attacker)
+ {
+ if (!this._statues.TryGetValue(npc, out var info))
+ {
+ return false;
+ }
+
+ if (info.Defender == this.GetTeam(attacker))
+ {
+ return true;
+ }
+
+ return this._statueGuardsAlive.TryGetValue(npc, out var aliveGuards) && aliveGuards > 0;
+ }
///
/// Gets the base spawn gate for the given team.
@@ -421,9 +455,13 @@ public class HeykelSavasiContext : MiniGameContext
// Re-show the HUD panel promptly after the battle-start map change.
await this.BroadcastHudStateAsync().ConfigureAwait(false);
- // Spawn the first (outermost) statue of each team's line; the opposing team attacks it.
- await this.SpawnStatueAsync(HeykelSavasiTeam.Red, 0).ConfigureAwait(false);
- await this.SpawnStatueAsync(HeykelSavasiTeam.Blue, 0).ConfigureAwait(false);
+ // Spawn ALL 7 statues (plus each statue's 3 guards) of both teams at their fixed positions, all
+ // visible from battle start; the opposing team may break them in any order (guards first).
+ for (var i = 0; i < StatuesToBreak; i++)
+ {
+ await this.SpawnStatueAsync(HeykelSavasiTeam.Red, i).ConfigureAwait(false);
+ await this.SpawnStatueAsync(HeykelSavasiTeam.Blue, i).ConfigureAwait(false);
+ }
}
///
@@ -498,12 +536,38 @@ public class HeykelSavasiContext : MiniGameContext
return;
}
- var attacker = Opponent(info.Defender);
- this.HandleStatueDestroyed(info.Defender, info.Index, attacker, spawnNext: true);
+ // The statue is gone; drop its guard bookkeeping too (its guards were already dead - a statue can
+ // only be hit once its 3 guards fall - but this keeps the maps from leaking a stale entry).
+ this._statueGuardsAlive.TryRemove(npc, out _);
- // Fire-and-forget the async side effects (buff application, spawning the next statue, or
- // finishing the event); the death event handler itself must stay synchronous.
- _ = this.OnStatueDestroyedSideEffectsAsync(info.Defender, info.Index, attacker);
+ var attacker = Opponent(info.Defender);
+
+ // Any-order, count-based progress: this break increments the attacker's running total (0..7),
+ // independent of which statue index fell.
+ this.HandleStatueDestroyed(info.Defender, info.Index, attacker, spawnNext: false);
+ var count = this.GetProgress(attacker);
+
+ // Fire-and-forget the async side effects (break announcement, buff application, or finishing the
+ // event); the death event handler itself must stay synchronous.
+ _ = this.OnStatueDestroyedSideEffectsAsync(attacker, count, e.KillerName);
+ }
+
+ ///
+ ///
+ /// ADAMU-CUSTOM (Heykel Savasi): guards spawned by are ordinary
+ /// s, so their Died event is auto-subscribed to this handler by
+ /// . When a guard dies we decrement the living-guard
+ /// count of the statue it protected; only once that count reaches 0 does
+ /// let players damage the statue.
+ ///
+ protected override void OnMonsterDied(object? sender, DeathInformation e)
+ {
+ base.OnMonsterDied(sender, e);
+
+ if (sender is AttackableNpcBase guard && this._guardToStatue.TryRemove(guard, out var statue))
+ {
+ this._statueGuardsAlive.AddOrUpdate(statue, 0, (_, alive) => alive > 0 ? alive - 1 : 0);
+ }
}
///
@@ -599,40 +663,48 @@ public class HeykelSavasiContext : MiniGameContext
}
///
- /// Handles the async side effects of a statue's destruction: applying the destruction buff to the
- /// attacking team, finishing the event if that statue was the winning one, or spawning the defender's
- /// next statue (plus guards) otherwise.
+ /// Handles the async side effects of a statue's destruction: announcing the break (running counter +
+ /// killer name) to all event players, applying the count-based destruction buff to the attacking team,
+ /// and finishing the event once the attacker has broken all of the
+ /// opponent's statues. Statues are no longer spawned here - all of them are spawned up-front at battle
+ /// start (see ).
///
- /// The team whose statue was destroyed.
- /// The index (0..6) of the destroyed statue in 's line.
/// The team which destroyed the statue.
- private async ValueTask OnStatueDestroyedSideEffectsAsync(HeykelSavasiTeam defender, int index, HeykelSavasiTeam attacker)
+ /// The attacker's running number of statues broken so far (1..).
+ /// The character name of the player who dealt the killing blow.
+ private async ValueTask OnStatueDestroyedSideEffectsAsync(HeykelSavasiTeam attacker, int count, string killerName)
{
try
{
- // One-shot HUD broadcast: the statue's progress counter was already updated synchronously by
+ // One-shot HUD broadcast: the progress counter was already updated synchronously by
// HandleStatueDestroyed (called from OnDestructibleDied before this method was scheduled), so every
// player sees the new progress immediately rather than waiting for the next periodic tick.
await this.BroadcastHudStateAsync().ConfigureAwait(false);
- await this.ApplyStatueBuffToTeamAsync(attacker, index).ConfigureAwait(false);
+ // Yellow center announcement to every event player: running counter + the destroyer's name.
+ var message = $"{count} Heykel {killerName} tarafindan yok edildi";
+ foreach (var player in this.PlayersOf(HeykelSavasiTeam.Red).Concat(this.PlayersOf(HeykelSavasiTeam.Blue)).ToList())
+ {
+ await player.InvokeViewPlugInAsync(
+ p => p.ShowMessageAsync(message, MUnique.OpenMU.Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false);
+ }
+
+ // Count-based buff: 1st break -> buff index 0, ... 6th -> index 5, 7th -> none (bounds guard).
+ await this.ApplyStatueBuffToTeamAsync(attacker, count - 1).ConfigureAwait(false);
if (this.GetWinner() == attacker)
{
this.FinishEvent();
- return;
}
-
- await this.SpawnStatueAsync(defender, index + 1).ConfigureAwait(false);
}
catch (Exception ex)
{
- this.Logger.LogError(ex, "{context}: Error handling statue-destroyed side effects for defender {defender}, index {index}.", this, defender, index);
+ this.Logger.LogError(ex, "{context}: Error handling statue-destroyed side effects for attacker {attacker}, count {count}.", this, attacker, count);
}
}
///
- /// Spawns 's statue at in its line, plus 4 guard
+ /// Spawns 's statue at in its line, plus 3 guard
/// mobs around it, using the same runtime-spawn API the map initializer itself uses
/// (; see also Castle Siege's
/// CastleSiegeEventPlugIn.SpawnCastleDefensesAsync for the reference pattern). The spawned statue
@@ -654,10 +726,9 @@ public class HeykelSavasiContext : MiniGameContext
return;
}
- // Each statue (and its guards) spawns at a RANDOM walkable spot anywhere on the map, so
- // after breaking one statue players must hunt for the next one across the arena. Falls back
- // to the fixed line position if the terrain can't yield a random coordinate.
- var pos = this.Map.Terrain.RandomWalkableCoordinate ?? positions[index];
+ // Statues are placed at FIXED coordinates and all spawned up-front, so both teams' 7 statues
+ // are visible from battle start and can be broken in any order.
+ var pos = positions[index];
var statueDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == StatueMonsterNumber);
if (statueDefinition is null)
@@ -666,8 +737,8 @@ public class HeykelSavasiContext : MiniGameContext
return;
}
- // Distinct spawn-index ranges per (defender, index), so repeated calls across the event
- // never collide: e.g. Red index 0 -> 1000..1004, Blue index 3 -> 2030..2034.
+ // Distinct spawn-index ranges per (defender, index), so the up-front spawns never collide:
+ // e.g. Red index 0 -> 1000..1003, Blue index 3 -> 2030..2033.
var spawnIndexBase = ((int)defender * 1000) + (index * 10);
var statueSpawnArea = new MonsterSpawnArea
@@ -683,11 +754,16 @@ public class HeykelSavasiContext : MiniGameContext
};
var statueNpc = await this._mapInitializer.InitializeSpawnAsync(spawnIndexBase, this.Map, statueSpawnArea, this).ConfigureAwait(false);
- if (statueNpc is AttackableNpcBase attackable)
+ if (statueNpc is not AttackableNpcBase statue)
{
- this._statues[attackable] = (defender, index);
+ return;
}
+ 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)
{
@@ -710,17 +786,14 @@ public class HeykelSavasiContext : MiniGameContext
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
};
- await this._mapInitializer.InitializeSpawnAsync(spawnIndexBase + 1 + i, this.Map, guardSpawnArea, this).ConfigureAwait(false);
- }
+ var guardNpc = await this._mapInitializer.InitializeSpawnAsync(spawnIndexBase + 1 + i, this.Map, guardSpawnArea, this).ConfigureAwait(false);
- // Statues spawn at random spots, so announce the new statue's coordinates to every event
- // player as a yellow center (GoldenCenter) message — only players inside the event see it.
- var teamName = defender == HeykelSavasiTeam.Red ? "RED" : "BLUE";
- var coordMessage = $"{teamName} STATUE appeared! Coordinate: {pos.X} , {pos.Y}";
- foreach (var announcePlayer in this.PlayersOf(HeykelSavasiTeam.Red).Concat(this.PlayersOf(HeykelSavasiTeam.Blue)).ToList())
- {
- await announcePlayer.InvokeViewPlugInAsync(
- p => p.ShowMessageAsync(coordMessage, MUnique.OpenMU.Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false);
+ // Guards are ordinary Monsters; their Died event is auto-subscribed to OnMonsterDied by
+ // MiniGameContext.OnObjectAddedToMapAsync. We only need to remember which statue each guards.
+ if (guardNpc is AttackableNpcBase guard)
+ {
+ this._guardToStatue[guard] = statue;
+ }
}
}
catch (Exception ex)
@@ -1017,21 +1090,28 @@ public class HeykelSavasiContext : MiniGameContext
public HeykelSavasiTeam GetWinner() => this._winner;
///
- /// Registers that destroyed the statue at in
- /// the opponent's line. Once a winner is set, further registrations are ignored.
+ /// Registers that destroyed one of the opponent's statues. Progress is a
+ /// running COUNT (statues can be broken in any order), so this simply increments the attacker's total
+ /// by one; once the count reaches the attacker is recorded as the winner.
+ /// Once a winner is set, further registrations are ignored.
///
- /// The index (0..6) of the destroyed statue.
+ ///
+ /// The index of the destroyed statue in the defender's line. No longer used for sequencing (breaks are
+ /// order-independent); retained for signature/call-site compatibility only.
+ ///
/// The team which destroyed the statue.
public void RegisterDestroyed(int index, HeykelSavasiTeam attacker)
{
+ _ = index;
if (this._winner != HeykelSavasiTeam.None)
{
return;
}
- this._progress[attacker] = index + 1;
+ var newCount = this.GetProgress(attacker) + 1;
+ this._progress[attacker] = newCount;
- if (index + 1 >= StatuesToBreak)
+ if (newCount >= StatuesToBreak)
{
this._winner = attacker;
}
diff --git a/src/GameLogic/NPC/AttackableNpcBase.cs b/src/GameLogic/NPC/AttackableNpcBase.cs
index bc3f6bf..80c8979 100644
--- a/src/GameLogic/NPC/AttackableNpcBase.cs
+++ b/src/GameLogic/NPC/AttackableNpcBase.cs
@@ -109,10 +109,11 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable
return null;
}
- // ADAMU-CUSTOM: Heykel Savasi -> a statue is immune to damage from its own team (only the enemy may break it).
+ // ADAMU-CUSTOM: Heykel Savasi -> a statue is immune to damage from its own team (only the enemy may
+ // break it) AND while any of its 3 guard mobs are still alive (guards must be cleared first).
if (attacker is Player heykelStatueAttacker
&& heykelStatueAttacker.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelStatueGame
- && heykelStatueGame.IsFriendlyStatue(this, heykelStatueAttacker))
+ && heykelStatueGame.IsStatueAttackBlocked(this, heykelStatueAttacker))
{
return null;
}