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)); + } }