// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic.MiniGames; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames; using MUnique.OpenMU.Pathfinding; /// /// The context of the Heykel Savasi (statue war) event. /// /// /// This is the foundation class for the event: it tracks which team every participating player /// belongs to, and enforces a team-balance rule when players attempt to join a team. /// public class HeykelSavasiContext : MiniGameContext { /// /// The maximum allowed difference between the red and blue team sizes after a join. /// private const int MaxTeamDifference = 2; /// /// The X1/Y1 anchor of the red team's base spawn gate. /// Must match Gates.cs targetGates 600 (RedBase). /// private const byte RedBaseAnchorX1 = 20; /// /// The X1/Y1 anchor of the red team's base spawn gate. /// Must match Gates.cs targetGates 600 (RedBase). /// private const byte RedBaseAnchorY1 = 20; /// /// The X1/Y1 anchor of the blue team's base spawn gate. /// Must match Gates.cs targetGates 601 (BlueBase). /// private const byte BlueBaseAnchorX1 = 220; /// /// The X1/Y1 anchor of the blue team's base spawn gate. /// Must match Gates.cs targetGates 601 (BlueBase). /// 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) }; /// /// 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 /// configuration (no custom MagicEffectDefinitions were added): 1=GreaterDamage, 2=GreaterDefense, /// 4=SoulBarrier, 5=CriticalDamageIncrease (DL), 0x52=WizEnhance (SM), 129=IgnoreDefense (RF). /// Index 6 (the 7th/final statue) intentionally has no entry - breaking it is the win condition, not a /// buff trigger; see the bounds check in . /// private static readonly short[] StatueBuffEffectNumbers = { 1, 2, 4, 5, 0x52, 129 }; /// /// The duration of the class buff granted on a statue break, chosen to comfortably outlast a single /// match (matches run longer than 5 minutes). /// 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). /// 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. /// private readonly object _teamLock = new(); /// /// Initializes a new instance of the class. /// /// The key of this context. /// The definition of the mini game. /// The game context, to which this game belongs. /// The map initializer, which is used when the event starts. public HeykelSavasiContext(MiniGameMapKey key, MiniGameDefinition definition, IGameContext gameContext, IMapInitializer mapInitializer) : base(key, definition, gameContext, mapInitializer) { this._gameContext = gameContext; this._mapInitializer = mapInitializer; } /// /// Gets a value indicating whether players are allowed to kill each other. /// /// /// This does not prevent friendly fire (team members killing each other); that restriction /// is added separately via a hook in a later phase. /// public override bool AllowPlayerKilling => this.State == MiniGameState.Playing; /// /// Gets the current number of players assigned to the given team. /// /// The team. /// The number of players currently assigned to . public int TeamCount(HeykelSavasiTeam team) => this._teams.Values.Count(t => t == team); /// /// Determines whether a player may currently join the given team, based on the team-balance rule. /// /// The team which a player wants to join. /// true if the join is allowed; otherwise, false. public bool CanJoin(HeykelSavasiTeam team) => IsJoinAllowed(this.TeamCount(HeykelSavasiTeam.Red), this.TeamCount(HeykelSavasiTeam.Blue), team); /// /// Assigns the given player to the given team. /// /// The player. /// The team. public void AssignTeam(Player player, HeykelSavasiTeam team) => this._teams[player] = team; /// /// Gets the team of the given player. /// /// The player. /// The team of the player, or if the player is not assigned to a team. public HeykelSavasiTeam GetTeam(Player player) => this._teams.TryGetValue(player, out var team) ? team : HeykelSavasiTeam.None; /// /// Gets the base spawn gate for the given team. /// /// The team. /// The which is the base spawn point of . /// /// The gate is resolved by matching the X1/Y1 anchor of the gates created for this event's map /// (see Gates.cs targetGates 600/601, added in Task 0.3). Red anchors at (20, 20), Blue at (220, 220). /// public ExitGate GetTeamSpawnGate(HeykelSavasiTeam team) { var (anchorX1, anchorY1) = team == HeykelSavasiTeam.Blue ? (BlueBaseAnchorX1, BlueBaseAnchorY1) : (RedBaseAnchorX1, RedBaseAnchorY1); return this.Definition.Entrance!.Map!.ExitGates.First(g => g.X1 == anchorX1 && g.Y1 == anchorY1); } /// /// Gets all players which are currently assigned to the given team. /// /// The team. /// All players assigned to . public IEnumerable PlayersOf(HeykelSavasiTeam team) => this._teams.Where(kv => kv.Value == team).Select(kv => kv.Key); /// /// Gets , exposed for unit tests only (see /// HeykelSavasiContextTests.StatueBuffMap_HasSixEntriesInExpectedOrder); the array itself stays /// private because it is an implementation detail of . /// internal static short[] StatueBuffEffectNumbersForTest => StatueBuffEffectNumbers; /// /// 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 /// assignment, then entering the mini game. If entering fails, the reservation is rolled back. /// /// The player who wants to join. /// The team which the player wants to join. /// true if the player successfully joined and entered the mini game; otherwise, false. public async ValueTask TryJoinTeamAsync(Player player, HeykelSavasiTeam team) { bool reserved; lock (this._teamLock) { reserved = this.State == MiniGameState.Open && this.GetTeam(player) == HeykelSavasiTeam.None && this.CanJoin(team); if (reserved) { // Reserve the slot synchronously so a concurrent join sees the updated team count. this.AssignTeam(player, team); } } if (!reserved) { return false; } var success = false; try { var enterResult = await this.TryEnterAsync(player).ConfigureAwait(false); success = enterResult == EnterResult.Success; } finally { if (!success) { lock (this._teamLock) { // Roll back the reservation; the player never actually entered the mini game // (or TryEnterAsync threw, e.g. due to a disconnect/disposal race). this.RemoveTeam(player); } } } return success; } /// /// Removes the given player's team reservation, if any. /// /// The player. private void RemoveTeam(Player player) => this._teams.TryRemove(player, out _); /// /// /// Cancels the event if either team is empty at start (no opponent to fight); otherwise /// warps every participant to their team's base spawn gate. /// protected override async ValueTask OnGameStartAsync(ICollection players) { await base.OnGameStartAsync(players).ConfigureAwait(false); if (this.TeamCount(HeykelSavasiTeam.Red) == 0 || this.TeamCount(HeykelSavasiTeam.Blue) == 0) { this.FinishEvent(); return; } foreach (var player in players) { 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(), }; } /// /// Determines, in a pure way, whether joining is allowed given the current /// team counts, according to the balance rule: the absolute difference between the red and blue /// team sizes must not exceed after the prospective join. /// /// The current number of players in the red team. /// The current number of players in the blue team. /// The team which a player wants to join. /// true if the join is allowed; otherwise, false. internal static bool IsJoinAllowed(int redCount, int blueCount, HeykelSavasiTeam team) { switch (team) { case HeykelSavasiTeam.Red: redCount++; break; case HeykelSavasiTeam.Blue: blueCount++; break; default: return false; } 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 { await this.ApplyStatueBuffToTeamAsync(attacker, index).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); } } /// /// 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); } } /// /// Applies the class buff mapped to (see ) /// to every player currently on . A no-op for the 7th statue (index 6, out of /// range), which is the win condition rather than a buff trigger. /// /// The attacking team, whose players receive the buff. /// The index (0..6) of the statue that was just destroyed. private async ValueTask ApplyStatueBuffToTeamAsync(HeykelSavasiTeam team, int statueIndex) { if (statueIndex < 0 || statueIndex >= StatueBuffEffectNumbers.Length) { return; } var effectNumber = StatueBuffEffectNumbers[statueIndex]; foreach (var player in this.PlayersOf(team).ToList()) { await this.ApplyEffectAsync(player, effectNumber).ConfigureAwait(false); } } /// /// Applies a single, already-existing (looked up by /// ) to , for . /// Mirrors the boost-construction pattern of ApplyMagicEffectConsumeHandlerPlugIn.ConsumeItemAsyncCore: /// each of the definition's PowerUpDefinitions is turned into a boost element bound to the /// player's own system, so the buff scales off that player's stats. /// /// The player to buff. /// The of the effect to apply. private async ValueTask ApplyEffectAsync(Player player, short effectNumber) { var def = player.GameContext.Configuration.MagicEffects.FirstOrDefault(m => m.Number == effectNumber); if (def is null || player.Attributes is null) { return; } var boosts = def.PowerUpDefinitions .Where(d => d.Boost is not null && d.TargetAttribute is not null) .Select(d => new MagicEffect.ElementWithTarget(player.Attributes.CreateElement(d), d.TargetAttribute!)) .ToArray(); if (boosts.Length == 0) { return; } var effect = new MagicEffect(BuffDuration, def, boosts!); await player.MagicEffectList.AddEffectAsync(effect).ConfigureAwait(false); } /// /// Re-applies every buff 's team has already earned (one per statue broken so /// far, indices [0..progress-1]) to . Intended for use on respawn (Task 5.3), /// since the existing buffs are configured with StopByDeath=true and are cleared on death. /// /// The player to re-buff. internal async ValueTask ReapplyBuffsAsync(Player player) { var team = this.GetTeam(player); var earned = this.GetProgress(team); for (var i = 0; i < earned && i < StatueBuffEffectNumbers.Length; i++) { await this.ApplyEffectAsync(player, StatueBuffEffectNumbers[i]).ConfigureAwait(false); } } /// Reapplies the team's earned buffs to a player who just (re)spawned. /// The player who (re)spawned. public ValueTask OnPlayerRespawnedAsync(Player player) => this.ReapplyBuffsAsync(player); /// /// 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; } } } }