// // 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; using System.Threading.Tasks; using MUnique.OpenMU.DataModel; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames; using MUnique.OpenMU.GameLogic.Views.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 = 42; /// /// The X1/Y1 anchor of the red team's base spawn gate. /// Must match Gates.cs targetGates 600 (RedBase). /// private const byte RedBaseAnchorY1 = 10; /// /// The X1/Y1 anchor of the blue team's base spawn gate. /// Must match Gates.cs targetGates 601 (BlueBase). /// private const byte BlueBaseAnchorX1 = 42; /// /// The X1/Y1 anchor of the blue team's base spawn gate. /// Must match Gates.cs targetGates 601 (BlueBase). /// private const byte BlueBaseAnchorY1 = 92; /// /// 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 fixed amount of Zen awarded to each member of the winning team when the event ends. /// /// /// This is the authoritative definition of the reward amount. It is defined here (in GameLogic), not in /// Persistence.Initialization.VersionSeasonSix.Events.HeykelSavasiInitializer where a same-named /// constant currently also exists, because MUnique.OpenMU.GameLogic does not - and must not - /// reference MUnique.OpenMU.Persistence.Initialization (the dependency only goes the other way; /// same constraint that forced into this class). The initializer's constant /// is unused by this class and will be reconciled/removed in a later cleanup pass. /// public const int WinnerZenReward = 10_000_000; /// /// 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 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) }; /// /// 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 /// 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); /// /// 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(43, 20), new(56, 26), new(72, 43), new(43, 28), new(43, 44), new(29, 26), new(14, 43), }; /// /// 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(16, 61), new(31, 77), new(43, 72), new(43, 58), new(72, 60), new(57, 76), new(43, 82), }; /// /// The duration of the preparation countdown between the entrance closing () /// and the battle starting (). Mirrors the hard-coded /// countdownMessageDuration in 's own game loop (MiniGameContext.cs:709), /// which isn't exposed to subclasses, so the HUD countdown re-declares the same constant here. /// private static readonly TimeSpan PreparationDuration = TimeSpan.FromSeconds(30); private readonly IGameContext _gameContext; private readonly IMapInitializer _mapInitializer; private readonly ConcurrentDictionary _teams = new(); /// /// UTC timestamp of when this context was constructed, used as the start reference for estimating the /// remaining time of the registration phase () in the periodic HUD broadcast /// (see ). /// private readonly DateTime _createdAtUtc = DateTime.UtcNow; /// /// UTC timestamp of the first observed transition into (entrance closed, /// preparation countdown running), lazily set by . /// private DateTime? _closedAtUtc; /// /// UTC timestamp of the first observed transition into (battle started), /// lazily set by . /// private DateTime? _playingAtUtc; /// /// 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(); /// /// 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 /// 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; _ = Task.Run(() => this.HudBroadcastLoopAsync(this.GameEndedToken), this.GameEndedToken); } /// /// 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; /// /// 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. /// /// 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. /// /// 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) { // Robust selection: the map has exactly two spawn gates (one per base). The red base sits at // the top of the arena (smaller Y), the blue base at the bottom (larger Y). Selecting by Y-order // avoids brittle exact-coordinate matching (gate coords can drift by a tile between data paths). var spawnGates = this.Definition.Entrance!.Map!.ExitGates .Where(g => g.IsSpawnGate) .OrderBy(g => g.Y1) .ToList(); if (spawnGates.Count == 0) { return this.Definition.Entrance!; } return team == HeykelSavasiTeam.Blue ? spawnGates[^1] : spawnGates[0]; } /// /// 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); } } } if (success) { // One-shot HUD broadcast so every already-entered player sees the updated team counts immediately, // rather than waiting for the next tick of the periodic broadcast (see HudBroadcastLoopAsync). await this.BroadcastHudStateAsync().ConfigureAwait(false); } return success; } /// /// Removes the given player's team reservation, if any. /// /// The player. private void RemoveTeam(Player player) => this._teams.TryRemove(player, out _); /// /// Removes a player from their team when they leave the event map for good (disconnect, or warp /// to another map such as Lorencia). A same-map death-respawn does NOT trigger this (RespawnAtAsync /// skips the map-removal when respawning on the same map), so dying at your own base keeps your team /// assignment and earned progress intact. This keeps team counts accurate so re-joining works. /// protected override async ValueTask OnObjectRemovedFromMapAsync((GameMap Map, ILocateable Object) args) { if (args.Object is Player player) { // Revert the uniform battle form before dropping the player from the roster, so a player who // leaves the event map mid-battle returns to their normal appearance. await this.RemoveTransformAsync(player).ConfigureAwait(false); this.RemoveTeam(player); } await base.OnObjectRemovedFromMapAsync(args).ConfigureAwait(false); } /// /// /// 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); } // Transform every participant into a single uniform battle form so their wings/mount/pet are // visually hidden and everyone shares the same base skin; the client adds the red/blue tint on top // via the per-player team roster (IHeykelSavasiTeamRosterPlugIn). Do this after the warp so the // appearance broadcast reaches the players' new (base map) scope. foreach (var player in players) { await this.TransformAsync(player).ConfigureAwait(false); } // Re-show the HUD panel promptly after the battle-start map change. await this.BroadcastHudStateAsync().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); } } /// /// /// Awards Zen to every member of the winning team (see /// ); no reward is given on a draw (). Mirrors /// the exact fallback pattern the framework itself uses for money rewards /// (MiniGameContext.GiveRewardAsync, MiniGameContext.cs:577-579): if a player's inventory can't /// hold the money, returns and the player is /// notified via . Deliberately does not announce the /// result via MiniGameContext.ShowGoldenMessageAsync: that overload treats its argument as a /// localization resource key (see e.g. BloodCastleContext.OnObjectRemovedFromMapAsync's /// nameof(PlayerMessage.BloodCastleCrystalStatusDestroyed) usage), not literal text, so there is no /// existing resource key for a Heykel Savasi win/draw announcement to pass here; adding one is out of /// scope for this task. Does not warp players itself either: once the base call below returns, the /// framework's own shutdown path calls MovePlayersToSafezoneAsync, which - because /// is no longer at that point - /// resolves every participant's respawn gate to the map's configured SafezoneMap (Lorencia). /// protected override async ValueTask GameEndedAsync(ICollection finishers) { var winner = this.GetWinner(); if (winner != HeykelSavasiTeam.None) { foreach (var player in this.PlayersOf(winner).ToList()) { if (!player.TryAddMoney(WinnerZenReward)) { await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.AwardMoneyFailByFullInventory)).ConfigureAwait(false); } } } // Revert the uniform battle form applied in OnGameStartAsync so players return to their normal appearance. foreach (var player in finishers) { await this.RemoveTransformAsync(player).ConfigureAwait(false); } // Final HUD broadcast: by now MiniGameContext.State is already MiniGameState.Ended (StopAsync sets it // before calling this method), so ComputeHudPhaseAndRemaining naturally reports phase 3/0 seconds. This // is needed because the periodic loop (HudBroadcastLoopAsync) stops exactly at this same state // transition (it runs off GameEndedToken, which StopAsync cancels right before setting State to Ended). await this.BroadcastHudStateAsync().ConfigureAwait(false); await base.GameEndedAsync(finishers).ConfigureAwait(false); } /// /// Gets the players which would receive the winner's Zen reward if the event ended right now, for unit /// tests only (a full run needs a live with a working /// /inventory, which isn't practical to construct in a fast unit test). /// /// The winning team's players, or an empty sequence if the event hasn't been decided yet. internal IEnumerable GetRewardTargetsForTest() => this.GetWinner() == HeykelSavasiTeam.None ? Enumerable.Empty() : this.PlayersOf(this.GetWinner()); /// /// /// 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; } // 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 _); 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); } } /// /// 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: 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 which destroyed the statue. /// 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 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); // 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(); } } catch (Exception ex) { 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 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 /// 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; } // 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) { this.Logger.LogWarning("{context}: Statue monster definition {number} not found.", this, StatueMonsterNumber); return; } // 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 { 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 not AttackableNpcBase statue) { 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) { 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, }; var guardNpc = await this._mapInitializer.InitializeSpawnAsync(spawnIndexBase + 1 + i, this.Map, guardSpawnArea, this).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) { 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, and - while the battle is running - /// re-transforms them into the uniform battle form (death clears the transformation skin) and re-shows the /// HUD panel promptly after the respawn map change. /// /// The player who (re)spawned. public async ValueTask OnPlayerRespawnedAsync(Player player) { await this.ReapplyBuffsAsync(player).ConfigureAwait(false); if (this.State == MiniGameState.Playing) { await this.TransformAsync(player).ConfigureAwait(false); await this.BroadcastHudStateAsync().ConfigureAwait(false); } } /// /// The of the transformation skin every participant is turned into /// for the duration of the battle: 14 = Skeleton Warrior, a caped, walking humanoid soldier that reads as a /// reasonable uniform battle form and hides the player's wings/mount/pet. The red/blue distinction is added /// separately by the client via the per-player team roster (). /// // 0 = no monster transformation: players keep their normal body during the event. The team look // (red/blue cape + hidden wings/mount/pet) is done client-side (World93 render override), mirroring the // Battle Soccer event. Set to a monster number here only if a full skin-transform form is desired instead. private const short EventFormSkin = 0; /// /// Transforms into the uniform battle form, mirroring /// the exact mechanism of SkinChatCommandPlugIn (the /skin command): it composes an /// element onto the player's /// attribute and sets the attribute value, which fires Player.OnTransformationSkinChanged and /// re-broadcasts the player's appearance to observers. /// /// The player to transform. private ValueTask TransformAsync(Player player) => this.SetTransformationSkinAsync(player, EventFormSkin); /// /// Reverts 's transformation by resetting to /// 0 (no transformation), which restores the player's normal appearance (wings/mount/pet included). Mirrors /// how /skin 0 removes the added skin element. /// /// The player to revert. private ValueTask RemoveTransformAsync(Player player) => this.SetTransformationSkinAsync(player, 0); /// /// Sets 's to exactly /// as SkinChatCommandPlugIn does: clear any previously composed elements, add a single /// element with the target value, then write the attribute value (which is /// what actually triggers the appearance re-broadcast). A value of 0 fully reverts to the natural appearance. /// /// The player whose transformation skin to set. /// The transformation skin number (0 = none/revert). private ValueTask SetTransformationSkinAsync(Player player, short skin) { if (player.Attributes is { } attributes && attributes.GetComposableAttribute(Stats.TransformationSkin) is { } attribute) { attribute.Elements.ToList().ForEach(attribute.RemoveElement); attribute.AddElement(attributes.CreateElement(new MUnique.OpenMU.Persistence.BasicModel.PowerUpDefinitionValue { AggregateType = MUnique.OpenMU.AttributeSystem.AggregateType.AddRaw, Value = skin }, Stats.TransformationSkin)); attributes[Stats.TransformationSkin] = skin; } return ValueTask.CompletedTask; } /// /// Periodically (about once per second) broadcasts the Heykel Savasi HUD state /// () to every entered player, covering the registration phase /// (), the preparation countdown (), and /// the battle itself (). Started once from the constructor via /// Task.Run (mirroring 's own pattern for its private game loop). /// /// /// Stops as soon as is cancelled, which happens exactly when /// the game transitions to (see MiniGameContext.StopAsync, which /// cancels the token and only then sets to /// before calling ); the final "ended" broadcast is sent separately, from /// itself, once is actually . /// /// The token which is cancelled once the game ends. private async ValueTask HudBroadcastLoopAsync(CancellationToken cancellationToken) { try { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); do { await this.BroadcastHudStateAsync().ConfigureAwait(false); await this.BroadcastTeamRosterAsync().ConfigureAwait(false); } while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)); } catch (OperationCanceledException) { // Expected: the game ended; see GameEndedAsync for the final broadcast. } catch (Exception ex) { this.Logger.LogError(ex, "{context}: Error in the HUD broadcast loop.", this); } } /// /// Sends the current Heykel Savasi HUD state (see ) to every /// entered player via . Called both periodically (see /// ) and as a one-shot update from (on a /// successful join), (on a statue break), and /// (final state). /// private async ValueTask BroadcastHudStateAsync() { var (phase, remainingSeconds) = this.ComputeHudPhaseAndRemaining(); var redCount = (byte)this.TeamCount(HeykelSavasiTeam.Red); var blueCount = (byte)this.TeamCount(HeykelSavasiTeam.Blue); var redProgress = (byte)this.GetProgress(HeykelSavasiTeam.Red); var blueProgress = (byte)this.GetProgress(HeykelSavasiTeam.Blue); await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.UpdateHudStateAsync(phase, (byte)this.GetTeam(player), redCount, blueCount, redProgress, blueProgress, remainingSeconds)) .AsTask()).ConfigureAwait(false); } /// /// Sends the current team roster (every participating player's network id and team) to every entered /// player via , so the client can tint each nearby event player /// red or blue. Broadcast on the same ~1s cadence as the HUD state (see ). /// private async ValueTask BroadcastTeamRosterAsync() { var entries = this._teams .Where(kv => kv.Value != HeykelSavasiTeam.None) .Select(kv => (kv.Key.Id, (byte)kv.Value)) .ToList(); await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.UpdateTeamRosterAsync(entries)) .AsTask()).ConfigureAwait(false); } /// /// Determines the current HUD phase and the estimated number of seconds remaining in it, purely from /// and wall-clock timestamps recorded (lazily, on first observation) at /// each relevant state transition. This is an approximation, not authoritative timing: /// is set at construction time, which happens moments before MiniGameContext.RunGameAsync's own /// Task.Run starts counting down , and /// / are set by whichever caller (the periodic loop or a /// one-shot broadcast) first observes the corresponding state - always within about a second of the real /// transition, which is precise enough for a HUD countdown. /// /// The current phase (0-3, see ) and the /// estimated remaining seconds in that phase. private (byte Phase, ushort RemainingSeconds) ComputeHudPhaseAndRemaining() { var now = DateTime.UtcNow; switch (this.State) { case MiniGameState.Open: return (0, ClampToUInt16Seconds(this.Definition.EnterDuration - (now - this._createdAtUtc))); case MiniGameState.Closed: this._closedAtUtc ??= now; return (1, ClampToUInt16Seconds(PreparationDuration - (now - this._closedAtUtc.Value))); case MiniGameState.Playing: this._playingAtUtc ??= now; return (2, ClampToUInt16Seconds(this.Definition.GameDuration - (now - this._playingAtUtc.Value))); default: // Undefined, Ended, or Disposed: the event is over (or hasn't been set up yet). return (3, 0); } } /// /// Converts to a seconds count for the HUD packet, /// clamping negative durations to 0. /// /// The remaining duration. private static ushort ClampToUInt16Seconds(TimeSpan remaining) { if (remaining <= TimeSpan.Zero) { return 0; } var seconds = remaining.TotalSeconds; return seconds >= ushort.MaxValue ? ushort.MaxValue : (ushort)seconds; } /// /// 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 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 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; } var newCount = this.GetProgress(attacker) + 1; this._progress[attacker] = newCount; if (newCount >= StatuesToBreak) { this._winner = attacker; } } } }