diff --git a/src/GameLogic/MiniGames/HeykelSavasiContext.cs b/src/GameLogic/MiniGames/HeykelSavasiContext.cs
index a1cb137..10b222e 100644
--- a/src/GameLogic/MiniGames/HeykelSavasiContext.cs
+++ b/src/GameLogic/MiniGames/HeykelSavasiContext.cs
@@ -10,7 +10,9 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames;
+using MUnique.OpenMU.Pathfinding;
///
/// The context of the Heykel Savasi (statue war) event.
@@ -50,8 +52,81 @@ public class HeykelSavasiContext : MiniGameContext
///
private const byte BlueBaseAnchorY1 = 220;
+ ///
+ /// The number of statues (per team) which need to be broken by the opposing team to win the event.
+ ///
+ public const int StatuesToBreak = 7;
+
+ ///
+ /// The of the destructible
+ /// statue NPC (see HeykelSavasiMap.CreateMonsters).
+ ///
+ private const short StatueMonsterNumber = 561;
+
+ ///
+ /// The of the guard NPC
+ /// which spawns around each statue (see HeykelSavasiMap.CreateMonsters).
+ ///
+ private const short GuardMonsterNumber = 580;
+
+ ///
+ /// The (dx, dy) offsets, relative to a statue's position, at which its 4 guards spawn.
+ ///
+ private static readonly (int Dx, int Dy)[] GuardOffsets = { (-2, 0), (2, 0), (0, -2), (0, 2) };
+
+ ///
+ /// 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).
+ ///
+ private static readonly Point[] RedStatuePositions =
+ {
+ new(100, 100),
+ new(88, 88),
+ new(77, 77),
+ new(65, 65),
+ new(53, 53),
+ new(42, 42),
+ new(30, 30),
+ };
+
+ ///
+ /// 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).
+ ///
+ private static readonly Point[] BlueStatuePositions =
+ {
+ new(140, 140),
+ new(152, 152),
+ new(163, 163),
+ new(175, 175),
+ new(187, 187),
+ new(198, 198),
+ new(210, 210),
+ };
+
+ private readonly IGameContext _gameContext;
+ private readonly IMapInitializer _mapInitializer;
+
private readonly ConcurrentDictionary _teams = new();
+ ///
+ /// Maps a currently-alive, spawned statue NPC to the team it defends and its index (0..6) in that
+ /// team's statue line. Populated by , consumed (and removed) by
+ /// once the statue is destroyed.
+ ///
+ private readonly ConcurrentDictionary _statues = 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
+ /// live and starts a background game loop via Task.Run).
+ ///
+ private readonly StatueProgressState _progress = new();
+
///
/// 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.
@@ -68,6 +143,8 @@ public class HeykelSavasiContext : MiniGameContext
public HeykelSavasiContext(MiniGameMapKey key, MiniGameDefinition definition, IGameContext gameContext, IMapInitializer mapInitializer)
: base(key, definition, gameContext, mapInitializer)
{
+ this._gameContext = gameContext;
+ this._mapInitializer = mapInitializer;
}
///
@@ -132,6 +209,19 @@ public class HeykelSavasiContext : MiniGameContext
/// All players assigned to .
public IEnumerable PlayersOf(HeykelSavasiTeam team) => this._teams.Where(kv => kv.Value == team).Select(kv => kv.Key);
+ ///
+ /// Gets the number of the given team's own statues which have been broken by the opposing team so far.
+ ///
+ /// The attacking team.
+ /// The number of the opponent's statues has destroyed (0..).
+ public int GetProgress(HeykelSavasiTeam attacker) => this._progress.GetProgress(attacker);
+
+ ///
+ /// Gets the team which has won the event by breaking all of the opponent's statues, if any.
+ ///
+ /// The winning team, or if the event hasn't been decided yet.
+ public HeykelSavasiTeam GetWinner() => this._progress.GetWinner();
+
///
/// Atomically attempts to join the given player to the given team, reserving a slot under
/// to fix a check-then-act race between the balance check and the team
@@ -207,6 +297,67 @@ public class HeykelSavasiContext : MiniGameContext
{
await player.WarpToAsync(this.GetTeamSpawnGate(this.GetTeam(player))).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);
+ }
+
+ ///
+ ///
+ /// Note: already subscribes every
+ /// added to to
+ /// (see MiniGameContext.cs), so statues spawned via
+ /// are routed here automatically; this override must not
+ /// subscribe Died again.
+ ///
+ protected override void OnDestructibleDied(object? sender, DeathInformation e)
+ {
+ base.OnDestructibleDied(sender, e);
+
+ if (sender is not AttackableNpcBase npc || !this._statues.TryRemove(npc, out var info))
+ {
+ return;
+ }
+
+ var attacker = Opponent(info.Defender);
+ this.HandleStatueDestroyed(info.Defender, info.Index, attacker, spawnNext: true);
+
+ // 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);
+ }
+
+ ///
+ /// Registers, for tests only, that destroyed 's
+ /// statue at , without triggering any spawn/warp/buff side effects.
+ ///
+ /// The team whose statue was destroyed.
+ /// The index (0..6) of the destroyed statue in 's line.
+ /// The team which destroyed the statue.
+ internal void RegisterStatueDestroyedForTest(HeykelSavasiTeam defender, int index, HeykelSavasiTeam attacker)
+ => this.HandleStatueDestroyed(defender, index, attacker, spawnNext: false);
+
+ ///
+ /// Gets the fixed sequence of 7 statue positions defending 's own line (attacked
+ /// by the opposing team). Index 0 is the outermost/first statue, index 6 is the final one.
+ ///
+ /// The defending team.
+ /// The 7 statue positions of 's line, or an empty array for .
+ ///
+ /// This is deliberately kept in the GameLogic project (rather than
+ /// Persistence.Initialization/VersionSeasonSix/Maps/HeykelSavasiMap.cs, as originally sketched in
+ /// the task brief) because MUnique.OpenMU.GameLogic does not - and must not - reference
+ /// MUnique.OpenMU.Persistence.Initialization; the dependency only goes the other way.
+ ///
+ internal static Point[] StatuePositions(HeykelSavasiTeam team)
+ {
+ return team switch
+ {
+ HeykelSavasiTeam.Red => RedStatuePositions,
+ HeykelSavasiTeam.Blue => BlueStatuePositions,
+ _ => Array.Empty(),
+ };
}
///
@@ -234,4 +385,201 @@ public class HeykelSavasiContext : MiniGameContext
return Math.Abs(redCount - blueCount) <= MaxTeamDifference;
}
+
+ ///
+ /// Gets the opposing team of . maps to itself.
+ ///
+ /// The team.
+ /// The opposing team.
+ private static HeykelSavasiTeam Opponent(HeykelSavasiTeam team) => team switch
+ {
+ HeykelSavasiTeam.Red => HeykelSavasiTeam.Blue,
+ HeykelSavasiTeam.Blue => HeykelSavasiTeam.Red,
+ _ => HeykelSavasiTeam.None,
+ };
+
+ ///
+ /// Pure state transition shared by the runtime death handler () and the
+ /// test-only : records the attacker's progress and, once
+ /// statues have fallen, the winner. Spawning the next statue and applying
+ /// buffs are runtime-only side effects, triggered separately by the caller when
+ /// is true (see ).
+ ///
+ /// The team whose statue was destroyed.
+ /// The index (0..6) of the destroyed statue in 's line.
+ /// The team which destroyed the statue.
+ ///
+ /// Unused by this pure method; documents, at call sites, whether the caller intends to trigger the
+ /// runtime spawn-next/buff/finish side effects afterwards ( from
+ /// ) or not ( from tests).
+ ///
+ private void HandleStatueDestroyed(HeykelSavasiTeam defender, int index, HeykelSavasiTeam attacker, bool spawnNext)
+ {
+ _ = defender;
+ _ = spawnNext;
+ this._progress.RegisterDestroyed(index, attacker);
+ }
+
+ ///
+ /// 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.
+ ///
+ /// 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)
+ {
+ try
+ {
+ // Task 5.1: apply buff here
+ 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);
+ }
+ }
+
+ ///
+ /// Spawns 's statue at in its line, plus 4 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
+ /// is registered in so can attribute its death to
+ /// the correct defender/index; its Died event does not need to be subscribed here because
+ /// already does so for every
+ /// added to .
+ ///
+ /// The team whose statue line is being extended.
+ /// The index (0..6) of the statue to spawn in 's line.
+ private async ValueTask SpawnStatueAsync(HeykelSavasiTeam defender, int index)
+ {
+ try
+ {
+ var positions = StatuePositions(defender);
+ if (index < 0 || index >= positions.Length)
+ {
+ this.Logger.LogWarning("{context}: Statue index {index} is out of range for defender {defender}.", this, index, defender);
+ return;
+ }
+
+ var pos = positions[index];
+
+ var statueDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == StatueMonsterNumber);
+ if (statueDefinition is null)
+ {
+ this.Logger.LogWarning("{context}: Statue monster definition {number} not found.", this, StatueMonsterNumber);
+ 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.
+ var spawnIndexBase = ((int)defender * 1000) + (index * 10);
+
+ var statueSpawnArea = new MonsterSpawnArea
+ {
+ MonsterDefinition = statueDefinition,
+ Quantity = 1,
+ X1 = pos.X,
+ X2 = pos.X,
+ Y1 = pos.Y,
+ Y2 = pos.Y,
+ Direction = Direction.South,
+ SpawnTrigger = SpawnTrigger.OnceAtEventStart,
+ };
+
+ var statueNpc = await this._mapInitializer.InitializeSpawnAsync(spawnIndexBase, this.Map, statueSpawnArea, this).ConfigureAwait(false);
+ if (statueNpc is AttackableNpcBase attackable)
+ {
+ this._statues[attackable] = (defender, index);
+ }
+
+ var guardDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GuardMonsterNumber);
+ if (guardDefinition is null)
+ {
+ this.Logger.LogWarning("{context}: Guard monster definition {number} not found.", this, GuardMonsterNumber);
+ return;
+ }
+
+ for (var i = 0; i < GuardOffsets.Length; i++)
+ {
+ var (dx, dy) = GuardOffsets[i];
+ 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),
+ Direction = Direction.South,
+ SpawnTrigger = SpawnTrigger.OnceAtEventStart,
+ };
+
+ await this._mapInitializer.InitializeSpawnAsync(spawnIndexBase + 1 + i, this.Map, guardSpawnArea, this).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Logger.LogError(ex, "{context}: Error spawning statue for defender {defender}, index {index}.", this, defender, index);
+ }
+ }
+
+ ///
+ /// Pure, dependency-free tracker for statue-break progress and win detection. Extracted out of
+ /// so it can be unit-tested directly, without constructing a full
+ /// context (whose constructor requires a live /
+ /// and starts a background game loop via Task.Run - impractical in a fast unit test; see also
+ /// , extracted for the same reason). Kept internal (rather than
+ /// private) specifically so MUnique.OpenMU.Tests can drive it directly.
+ ///
+ internal sealed class StatueProgressState
+ {
+ private readonly ConcurrentDictionary _progress = new()
+ {
+ [HeykelSavasiTeam.Red] = 0,
+ [HeykelSavasiTeam.Blue] = 0,
+ };
+
+ private HeykelSavasiTeam _winner = HeykelSavasiTeam.None;
+
+ ///
+ /// Gets the number of the opponent's statues has destroyed so far.
+ ///
+ /// The attacking team.
+ public int GetProgress(HeykelSavasiTeam attacker) => this._progress.TryGetValue(attacker, out var value) ? value : 0;
+
+ ///
+ /// Gets the winning team, or if undecided.
+ ///
+ 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.
+ ///
+ /// The index (0..6) of the destroyed statue.
+ /// The team which destroyed the statue.
+ public void RegisterDestroyed(int index, HeykelSavasiTeam attacker)
+ {
+ if (this._winner != HeykelSavasiTeam.None)
+ {
+ return;
+ }
+
+ this._progress[attacker] = index + 1;
+
+ if (index + 1 >= StatuesToBreak)
+ {
+ this._winner = attacker;
+ }
+ }
+ }
}
diff --git a/tests/MUnique.OpenMU.Tests/HeykelSavasi/HeykelSavasiContextTests.cs b/tests/MUnique.OpenMU.Tests/HeykelSavasi/HeykelSavasiContextTests.cs
index c085724..28c8f6a 100644
--- a/tests/MUnique.OpenMU.Tests/HeykelSavasi/HeykelSavasiContextTests.cs
+++ b/tests/MUnique.OpenMU.Tests/HeykelSavasi/HeykelSavasiContextTests.cs
@@ -34,4 +34,51 @@ public class HeykelSavasiContextTests
{
Assert.That(HeykelSavasiContext.IsJoinAllowed(0, 0, HeykelSavasiTeam.None), Is.False);
}
+
+ ///
+ /// Exercises the statue-break progress/win-detection transition used by
+ /// HeykelSavasiContext.HandleStatueDestroyed (runtime) and
+ /// HeykelSavasiContext.RegisterStatueDestroyedForTest (test-only wrapper around it).
+ ///
+ ///
+ /// Like above, the progress/winner transition is
+ /// extracted into a small dependency-free type, ,
+ /// so it can be driven directly here instead of through a fully constructed
+ /// (which needs a live game context/map initializer and starts a
+ /// background game loop).
+ ///
+ [Test]
+ public void StatueProgress_IncrementsAndDetectsWin()
+ {
+ var state = new HeykelSavasiContext.StatueProgressState();
+
+ // Red breaks Blue's 7 statues, one by one; the winner must not be set before the 7th.
+ for (var i = 0; i < 7; i++)
+ {
+ Assert.That(state.GetWinner(), Is.EqualTo(HeykelSavasiTeam.None));
+ state.RegisterDestroyed(i, HeykelSavasiTeam.Red);
+ }
+
+ Assert.That(state.GetWinner(), Is.EqualTo(HeykelSavasiTeam.Red));
+ Assert.That(state.GetProgress(HeykelSavasiTeam.Red), Is.EqualTo(7));
+ }
+
+ [Test]
+ public void StatueProgress_IgnoresFurtherRegistrationsAfterWinnerIsSet()
+ {
+ var state = new HeykelSavasiContext.StatueProgressState();
+
+ for (var i = 0; i < 7; i++)
+ {
+ state.RegisterDestroyed(i, HeykelSavasiTeam.Blue);
+ }
+
+ Assert.That(state.GetWinner(), Is.EqualTo(HeykelSavasiTeam.Blue));
+
+ // A further "destruction" for the other team must not overwrite the already-decided winner.
+ state.RegisterDestroyed(0, HeykelSavasiTeam.Red);
+
+ Assert.That(state.GetWinner(), Is.EqualTo(HeykelSavasiTeam.Blue));
+ Assert.That(state.GetProgress(HeykelSavasiTeam.Red), Is.EqualTo(0));
+ }
}