1427 lines
71 KiB
C#
1427 lines
71 KiB
C#
// <copyright file="HeykelSavasiContext.cs" company="MUnique">
|
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
|
// </copyright>
|
|
|
|
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;
|
|
|
|
/// <summary>
|
|
/// The context of the Heykel Savasi (statue war) event.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public class HeykelSavasiContext : MiniGameContext
|
|
{
|
|
/// <summary>
|
|
/// The maximum allowed difference between the red and blue team sizes after a join.
|
|
/// </summary>
|
|
private const int MaxTeamDifference = 2;
|
|
|
|
/// <summary>
|
|
/// The X1/Y1 anchor of the red team's base spawn gate.
|
|
/// Must match Gates.cs targetGates 600 (RedBase).
|
|
/// </summary>
|
|
private const byte RedBaseAnchorX1 = 42;
|
|
|
|
/// <summary>
|
|
/// The X1/Y1 anchor of the red team's base spawn gate.
|
|
/// Must match Gates.cs targetGates 600 (RedBase).
|
|
/// </summary>
|
|
private const byte RedBaseAnchorY1 = 10;
|
|
|
|
/// <summary>
|
|
/// The X1/Y1 anchor of the blue team's base spawn gate.
|
|
/// Must match Gates.cs targetGates 601 (BlueBase).
|
|
/// </summary>
|
|
private const byte BlueBaseAnchorX1 = 42;
|
|
|
|
/// <summary>
|
|
/// The X1/Y1 anchor of the blue team's base spawn gate.
|
|
/// Must match Gates.cs targetGates 601 (BlueBase).
|
|
/// </summary>
|
|
private const byte BlueBaseAnchorY1 = 92;
|
|
|
|
/// <summary>
|
|
/// The number of statues (per team) which need to be broken by the opposing team to win the event.
|
|
/// </summary>
|
|
public const int StatuesToBreak = 7;
|
|
|
|
/// <summary>
|
|
/// The fixed amount of Zen awarded to each member of the winning team when the event ends.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This is the authoritative definition of the reward amount. It is defined here (in GameLogic), not in
|
|
/// <c>Persistence.Initialization.VersionSeasonSix.Events.HeykelSavasiInitializer</c> where a same-named
|
|
/// constant currently also exists, because <c>MUnique.OpenMU.GameLogic</c> does not - and must not -
|
|
/// reference <c>MUnique.OpenMU.Persistence.Initialization</c> (the dependency only goes the other way;
|
|
/// same constraint that forced <see cref="StatuePositions"/> into this class). The initializer's constant
|
|
/// is unused by this class and will be reconciled/removed in a later cleanup pass.
|
|
/// </remarks>
|
|
public const int WinnerZenReward = 10_000_000;
|
|
|
|
/// <summary>
|
|
/// The <see cref="MUnique.OpenMU.DataModel.Configuration.MonsterDefinition.Number"/> of the destructible
|
|
/// statue NPC (see <c>HeykelSavasiMap.CreateMonsters</c>).
|
|
/// </summary>
|
|
private const short StatueMonsterNumber = 561;
|
|
|
|
/// <summary>
|
|
/// The <see cref="MUnique.OpenMU.DataModel.Configuration.MonsterDefinition.Number"/> of the guard NPC
|
|
/// which spawns around each statue (see <c>HeykelSavasiMap.CreateMonsters</c>).
|
|
/// </summary>
|
|
private const short GuardMonsterNumber = 580;
|
|
|
|
/// <summary>
|
|
/// The radius (in tiles) of the box around a statue in which its guards spawn. A BOX (rather than fixed
|
|
/// offsets) lets the spawn logic retry to find a WALKABLE tile: several statues sit next to
|
|
/// non-walkable terrain (water/arena edges) where a fixed offset would land off-map, the guard would
|
|
/// silently fail to spawn, and the statue would stay permanently unbreakable (its guard-alive count
|
|
/// could never reach 0). See <see cref="SpawnStatueAsync"/>.
|
|
/// </summary>
|
|
private const int GuardSpawnRadius = 2;
|
|
|
|
/// <summary>
|
|
/// The number of guard mobs which spawn next to (and protect) each statue.
|
|
/// </summary>
|
|
private const int GuardsPerStatue = 3;
|
|
|
|
/// <summary>
|
|
/// The total number of statues (both teams combined) that must be broken before the 4 arena bosses
|
|
/// spawn (see <see cref="SpawnBossesAsync"/>). Requested by design: "these bosses spawn when a total
|
|
/// of 3 statues have been broken".
|
|
/// </summary>
|
|
private const int BossSpawnAtTotalDestroyed = 3;
|
|
|
|
/// <summary>
|
|
/// The 4 arena bosses (existing S6 boss monster numbers, so the client already has their models) and
|
|
/// the fixed coordinates at which they spawn once <see cref="BossSpawnAtTotalDestroyed"/> statues have
|
|
/// fallen: 306=Death Rider, 309=Hell Maine, 357=Genocider, 459=Selupan.
|
|
/// </summary>
|
|
private static readonly (short Number, byte X, byte Y)[] BossSpawns =
|
|
{
|
|
(306, 55, 42),
|
|
(309, 32, 42),
|
|
(357, 31, 59),
|
|
(459, 56, 58),
|
|
};
|
|
|
|
/// <summary>
|
|
/// TEST-PHASE statue health: overrides the statue definition's real (3,000,000) health at spawn via
|
|
/// <see cref="MonsterSpawnArea.MaximumHealthOverride"/>, so statues can be fully broken within a match
|
|
/// while tuning. Applied purely at spawn time (no DB/config change); raise or remove after testing.
|
|
/// </summary>
|
|
private const int StatueTestHealth = 100_000;
|
|
|
|
/// <summary>
|
|
/// Assembly-qualified type name of the NPC intelligence used to make the statue guards completely
|
|
/// STATIONARY (no wandering, no chasing): they stay at their fixed spawn tile next to the statue until
|
|
/// killed. Applied by overriding the guard definition's <c>IntelligenceTypeName</c> at runtime (see
|
|
/// <see cref="SpawnStatueAsync"/>), so no configuration/DB change is needed.
|
|
/// </summary>
|
|
private const string StationaryGuardIntelligenceTypeName = "MUnique.OpenMU.GameLogic.NPC.NullMonsterIntelligence, MUnique.OpenMU.GameLogic";
|
|
|
|
/// <summary>
|
|
/// Maps a destroyed statue's index (0..5) to the <see cref="MUnique.OpenMU.DataModel.Configuration.MagicEffectDefinition.Number"/>
|
|
/// of the class buff granted to the whole attacking team. All of these effects already exist in
|
|
/// configuration (no custom <c>MagicEffectDefinition</c>s 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 <see cref="ApplyStatueBuffToTeamAsync"/>.
|
|
/// </summary>
|
|
private static readonly short[] StatueBuffEffectNumbers = { 1, 2, 4, 5, 0x52, 129 };
|
|
|
|
/// <summary>
|
|
/// The duration of the class buff granted on a statue break, chosen to comfortably outlast a single
|
|
/// match (matches run longer than 5 minutes).
|
|
/// </summary>
|
|
private static readonly TimeSpan BuffDuration = TimeSpan.FromMinutes(6);
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="StatueBuffEffectNumbers"/>), not for spawn sequencing.
|
|
/// </summary>
|
|
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),
|
|
};
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="StatueBuffEffectNumbers"/>), not for spawn sequencing.
|
|
/// </summary>
|
|
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),
|
|
};
|
|
|
|
/// <summary>
|
|
/// The duration of the preparation countdown between the entrance closing (<see cref="MiniGameState.Closed"/>)
|
|
/// and the battle starting (<see cref="MiniGameState.Playing"/>). Mirrors the hard-coded
|
|
/// <c>countdownMessageDuration</c> in <see cref="MiniGameContext"/>'s own game loop (MiniGameContext.cs:709),
|
|
/// which isn't exposed to subclasses, so the HUD countdown re-declares the same constant here.
|
|
/// </summary>
|
|
private static readonly TimeSpan PreparationDuration = TimeSpan.FromSeconds(30);
|
|
|
|
private readonly IGameContext _gameContext;
|
|
private readonly IMapInitializer _mapInitializer;
|
|
|
|
private readonly ConcurrentDictionary<Player, HeykelSavasiTeam> _teams = new();
|
|
|
|
/// <summary>
|
|
/// UTC timestamp of when this context was constructed, used as the start reference for estimating the
|
|
/// remaining time of the registration phase (<see cref="MiniGameState.Open"/>) in the periodic HUD broadcast
|
|
/// (see <see cref="ComputeHudPhaseAndRemaining"/>).
|
|
/// </summary>
|
|
private readonly DateTime _createdAtUtc = DateTime.UtcNow;
|
|
|
|
/// <summary>
|
|
/// UTC timestamp of the first observed transition into <see cref="MiniGameState.Closed"/> (entrance closed,
|
|
/// preparation countdown running), lazily set by <see cref="ComputeHudPhaseAndRemaining"/>.
|
|
/// </summary>
|
|
private DateTime? _closedAtUtc;
|
|
|
|
/// <summary>
|
|
/// UTC timestamp of the first observed transition into <see cref="MiniGameState.Playing"/> (battle started),
|
|
/// lazily set by <see cref="ComputeHudPhaseAndRemaining"/>.
|
|
/// </summary>
|
|
private DateTime? _playingAtUtc;
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="SpawnStatueAsync"/>, consumed (and removed) by
|
|
/// <see cref="OnDestructibleDied"/> once the statue is destroyed.
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<AttackableNpcBase, (HeykelSavasiTeam Defender, int Index)> _statues = new();
|
|
|
|
/// <summary>
|
|
/// Maps each spawned statue NPC to the number of its guard mobs which are still alive. A statue starts at
|
|
/// <see cref="GuardsPerStatue"/> and can only be damaged once this reaches 0 (see
|
|
/// <see cref="IsStatueAttackBlocked"/>); decremented by <see cref="OnMonsterDied"/> as guards die.
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<AttackableNpcBase, int> _statueGuardsAlive = new();
|
|
|
|
/// <summary>
|
|
/// Maps each spawned guard mob to the statue it protects, so <see cref="OnMonsterDied"/> can decrement the
|
|
/// right statue's living-guard count when a guard dies. The guard is removed once its death is processed.
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<AttackableNpcBase, AttackableNpcBase> _guardToStatue = new();
|
|
|
|
/// <summary>
|
|
/// Pure statue-break progress/winner state, extracted into its own dependency-free type so it can be
|
|
/// unit-tested without constructing a full <see cref="HeykelSavasiContext"/> (whose constructor needs a
|
|
/// live <see cref="IGameContext"/> and starts a background game loop via <c>Task.Run</c>).
|
|
/// </summary>
|
|
private readonly StatueProgressState _progress = new();
|
|
|
|
/// <summary>
|
|
/// Guards the one-shot arena boss spawn (see <see cref="SpawnBossesAsync"/>): set to 1 the first time
|
|
/// <see cref="BossSpawnAtTotalDestroyed"/> statues have fallen, so the 4 bosses spawn exactly once.
|
|
/// </summary>
|
|
private int _bossesSpawned;
|
|
|
|
/// <summary>
|
|
/// Per-character contribution stats for the scoreboard (<see cref="IHeykelSavasiScoreboardPlugIn"/>):
|
|
/// enemy kills, statues broken, and total damage dealt to statues. Keyed by the participating player;
|
|
/// fed by <see cref="RecordKill"/>, <see cref="RecordStatueBreak"/> and <see cref="RecordStatueDamage"/>.
|
|
/// </summary>
|
|
private readonly ConcurrentDictionary<Player, PlayerScore> _scores = new();
|
|
|
|
/// <summary>
|
|
/// The maximum number of characters shown on the scoreboard (top contributors by statue damage).
|
|
/// </summary>
|
|
private const int ScoreboardMaxEntries = 10;
|
|
|
|
/// <summary>
|
|
/// How long the post-battle "event over" ceremony lasts before players are teleported to Lorencia.
|
|
/// Matches <c>MiniGameContext.RunGameAsync</c>'s own exit timing for our config (ExitDuration 15s):
|
|
/// <c>max(ExitDuration - 30s, 30s) + 30s countdown = 60s</c>. The client shows this as a countdown and
|
|
/// moves the scoreboard to the screen centre during it (phase 3).
|
|
/// </summary>
|
|
private static readonly TimeSpan EndCeremonyDuration = TimeSpan.FromSeconds(60);
|
|
|
|
/// <summary>
|
|
/// Wall-clock instant at which the post-battle ceremony ends and players are teleported out; set in
|
|
/// <see cref="GameEndedAsync"/>. Drives the phase-3 countdown in <see cref="ComputeHudPhaseAndRemaining"/>.
|
|
/// </summary>
|
|
private DateTime? _endCeremonyDeadlineUtc;
|
|
|
|
/// <summary>
|
|
/// Synchronizes the check-then-act sequence of <see cref="TryJoinTeamAsync"/>, so that concurrent
|
|
/// join attempts cannot both pass the balance check before either of them reserves a slot.
|
|
/// </summary>
|
|
private readonly object _teamLock = new();
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="HeykelSavasiContext"/> class.
|
|
/// </summary>
|
|
/// <param name="key">The key of this context.</param>
|
|
/// <param name="definition">The definition of the mini game.</param>
|
|
/// <param name="gameContext">The game context, to which this game belongs.</param>
|
|
/// <param name="mapInitializer">The map initializer, which is used when the event starts.</param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether players are allowed to kill each other.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This does not prevent friendly fire (team members killing each other); that restriction
|
|
/// is added separately via a <see cref="Player"/> hook in a later phase.
|
|
/// </remarks>
|
|
public override bool AllowPlayerKilling => this.State == MiniGameState.Playing;
|
|
|
|
/// <summary>
|
|
/// Gets the current number of players assigned to the given team.
|
|
/// </summary>
|
|
/// <param name="team">The team.</param>
|
|
/// <returns>The number of players currently assigned to <paramref name="team"/>.</returns>
|
|
public int TeamCount(HeykelSavasiTeam team) => this._teams.Values.Count(t => t == team);
|
|
|
|
/// <summary>
|
|
/// Determines whether a player may currently join the given team, based on the team-balance rule.
|
|
/// </summary>
|
|
/// <param name="team">The team which a player wants to join.</param>
|
|
/// <returns><c>true</c> if the join is allowed; otherwise, <c>false</c>.</returns>
|
|
public bool CanJoin(HeykelSavasiTeam team) => IsJoinAllowed(this.TeamCount(HeykelSavasiTeam.Red), this.TeamCount(HeykelSavasiTeam.Blue), team);
|
|
|
|
/// <summary>
|
|
/// Assigns the given player to the given team.
|
|
/// </summary>
|
|
/// <param name="player">The player.</param>
|
|
/// <param name="team">The team.</param>
|
|
public void AssignTeam(Player player, HeykelSavasiTeam team) => this._teams[player] = team;
|
|
|
|
/// <summary>
|
|
/// Gets the team of the given player.
|
|
/// </summary>
|
|
/// <param name="player">The player.</param>
|
|
/// <returns>The team of the player, or <see cref="HeykelSavasiTeam.None"/> if the player is not assigned to a team.</returns>
|
|
public HeykelSavasiTeam GetTeam(Player player) => this._teams.TryGetValue(player, out var team) ? team : HeykelSavasiTeam.None;
|
|
|
|
/// <summary>
|
|
/// Returns <c>true</c> if a hit on <paramref name="npc"/> by <paramref name="attacker"/> 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 <paramref name="npc"/> is not a tracked statue, the hit is never
|
|
/// blocked here (normal damage). Called from <c>AttackableNpcBase.AttackByAsync</c>.
|
|
/// </summary>
|
|
/// <param name="npc">The NPC being attacked.</param>
|
|
/// <param name="attacker">The attacking player.</param>
|
|
/// <returns><c>true</c> if the hit should be blocked; otherwise, <c>false</c>.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the base spawn gate for the given team.
|
|
/// </summary>
|
|
/// <param name="team">The team.</param>
|
|
/// <returns>The <see cref="ExitGate"/> which is the base spawn point of <paramref name="team"/>.</returns>
|
|
/// <remarks>
|
|
/// 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).
|
|
/// </remarks>
|
|
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];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all players which are currently assigned to the given team.
|
|
/// </summary>
|
|
/// <param name="team">The team.</param>
|
|
/// <returns>All players assigned to <paramref name="team"/>.</returns>
|
|
public IEnumerable<Player> PlayersOf(HeykelSavasiTeam team) => this._teams.Where(kv => kv.Value == team).Select(kv => kv.Key);
|
|
|
|
/// <summary>
|
|
/// Gets <see cref="StatueBuffEffectNumbers"/>, exposed for unit tests only (see
|
|
/// <c>HeykelSavasiContextTests.StatueBuffMap_HasSixEntriesInExpectedOrder</c>); the array itself stays
|
|
/// <c>private</c> because it is an implementation detail of <see cref="ApplyStatueBuffToTeamAsync"/>.
|
|
/// </summary>
|
|
internal static short[] StatueBuffEffectNumbersForTest => StatueBuffEffectNumbers;
|
|
|
|
/// <summary>
|
|
/// Gets the number of the given team's own statues which have been broken by the opposing team so far.
|
|
/// </summary>
|
|
/// <param name="attacker">The attacking team.</param>
|
|
/// <returns>The number of the opponent's statues <paramref name="attacker"/> has destroyed (0..<see cref="StatuesToBreak"/>).</returns>
|
|
public int GetProgress(HeykelSavasiTeam attacker) => this._progress.GetProgress(attacker);
|
|
|
|
/// <summary>
|
|
/// Gets the team which has won the event by breaking all of the opponent's statues, if any.
|
|
/// </summary>
|
|
/// <returns>The winning team, or <see cref="HeykelSavasiTeam.None"/> if the event hasn't been decided yet.</returns>
|
|
public HeykelSavasiTeam GetWinner() => this._progress.GetWinner();
|
|
|
|
/// <summary>
|
|
/// Atomically attempts to join the given player to the given team, reserving a slot under
|
|
/// <see cref="_teamLock"/> 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.
|
|
/// </summary>
|
|
/// <param name="player">The player who wants to join.</param>
|
|
/// <param name="team">The team which the player wants to join.</param>
|
|
/// <returns><c>true</c> if the player successfully joined <paramref name="team"/> and entered the mini game; otherwise, <c>false</c>.</returns>
|
|
public async ValueTask<bool> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes the given player's team reservation, if any.
|
|
/// </summary>
|
|
/// <param name="player">The player.</param>
|
|
private void RemoveTeam(Player player) => this._teams.TryRemove(player, out _);
|
|
|
|
/// <summary>
|
|
/// Records that <paramref name="killer"/> killed an enemy-team player (for the scoreboard kill count).
|
|
/// Called from <c>Player.AfterKilledPlayerAsync</c> when both players are in this event on opposing teams.
|
|
/// </summary>
|
|
/// <param name="killer">The killing player.</param>
|
|
public void RecordKill(Player killer)
|
|
{
|
|
if (killer is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var score = this._scores.GetOrAdd(killer, _ => new PlayerScore());
|
|
Interlocked.Increment(ref score.Kills);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records that the character named <paramref name="killerName"/> broke a statue (dealt the killing blow),
|
|
/// for the scoreboard statue count. Called from <see cref="OnDestructibleDied"/>, which only knows the
|
|
/// killer's name; it is matched to the participating player of that name.
|
|
/// </summary>
|
|
/// <param name="killerName">The character name of the player who broke the statue.</param>
|
|
public void RecordStatueBreak(string killerName)
|
|
{
|
|
if (string.IsNullOrEmpty(killerName))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var player = this._teams.Keys.FirstOrDefault(p => string.Equals(p.Name, killerName, StringComparison.Ordinal));
|
|
if (player is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var score = this._scores.GetOrAdd(player, _ => new PlayerScore());
|
|
Interlocked.Increment(ref score.Statues);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records <paramref name="damage"/> dealt by <paramref name="player"/> to <paramref name="statue"/>, for the
|
|
/// scoreboard damage ranking. Called from <c>AttackableNpcBase.AttackByAsync</c> for every hit that lands on a
|
|
/// statue of this event.
|
|
/// </summary>
|
|
/// <param name="player">The attacking player.</param>
|
|
/// <param name="statue">The statue NPC that was hit.</param>
|
|
/// <param name="damage">The health damage dealt by this hit.</param>
|
|
public void RecordStatueDamage(Player player, AttackableNpcBase statue, uint damage)
|
|
{
|
|
if (player is null || damage == 0 || !this._statues.ContainsKey(statue))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var score = this._scores.GetOrAdd(player, _ => new PlayerScore());
|
|
Interlocked.Add(ref score.Damage, damage);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
protected override async ValueTask OnGameStartAsync(ICollection<Player> 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);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Awards <see cref="WinnerZenReward"/> Zen to every member of the winning team (see
|
|
/// <see cref="GetWinner"/>); no reward is given on a draw (<see cref="HeykelSavasiTeam.None"/>). Mirrors
|
|
/// the exact fallback pattern the framework itself uses for money rewards
|
|
/// (<c>MiniGameContext.GiveRewardAsync</c>, MiniGameContext.cs:577-579): if a player's inventory can't
|
|
/// hold the money, <see cref="Player.TryAddMoney"/> returns <see langword="false"/> and the player is
|
|
/// notified via <see cref="Player.ShowLocalizedBlueMessageAsync"/>. Deliberately does not announce the
|
|
/// result via <c>MiniGameContext.ShowGoldenMessageAsync</c>: that overload treats its argument as a
|
|
/// localization resource key (see e.g. <c>BloodCastleContext.OnObjectRemovedFromMapAsync</c>'s
|
|
/// <c>nameof(PlayerMessage.BloodCastleCrystalStatusDestroyed)</c> 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 <c>MovePlayersToSafezoneAsync</c>, which - because
|
|
/// <see cref="MiniGameContext.State"/> is no longer <see cref="MiniGameState.Playing"/> at that point -
|
|
/// resolves every participant's respawn gate to the map's configured <c>SafezoneMap</c> (Lorencia).
|
|
/// </remarks>
|
|
protected override async ValueTask GameEndedAsync(ICollection<Player> 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);
|
|
}
|
|
|
|
// Announce the result + start the post-battle countdown to the Lorencia teleport.
|
|
this._endCeremonyDeadlineUtc = DateTime.UtcNow.Add(EndCeremonyDuration);
|
|
|
|
var resultMessage = winner switch
|
|
{
|
|
HeykelSavasiTeam.Red => "RED team wins the TvT Event!",
|
|
HeykelSavasiTeam.Blue => "BLUE team wins the TvT Event!",
|
|
_ => "TvT Event is over - it's a draw!",
|
|
};
|
|
foreach (var player in finishers)
|
|
{
|
|
await player.InvokeViewPlugInAsync<MUnique.OpenMU.GameLogic.Views.IShowMessagePlugIn>(
|
|
p => p.ShowMessageAsync(resultMessage, MUnique.OpenMU.Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false);
|
|
}
|
|
|
|
// Final HUD broadcast: by now MiniGameContext.State is already MiniGameState.Ended (StopAsync sets it
|
|
// before calling this method), so ComputeHudPhaseAndRemaining reports phase 3 with the ceremony countdown.
|
|
// The periodic HUD loop (HudBroadcastLoopAsync) stopped at this same transition, so a dedicated ceremony
|
|
// loop keeps the phase-3 countdown and the final scoreboard flowing until the teleport.
|
|
await this.BroadcastHudStateAsync().ConfigureAwait(false);
|
|
await this.BroadcastScoreboardAsync().ConfigureAwait(false);
|
|
_ = Task.Run(() => this.EndCeremonyBroadcastLoopAsync(this._endCeremonyDeadlineUtc.Value));
|
|
|
|
await base.GameEndedAsync(finishers).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// After the battle ends, keeps broadcasting the phase-3 HUD state (with the countdown to the Lorencia
|
|
/// teleport) and the final scoreboard about once per second until the ceremony deadline. Runs independently
|
|
/// of <see cref="HudBroadcastLoopAsync"/>, which has already stopped (its <see cref="MiniGameContext.GameEndedToken"/>
|
|
/// was cancelled when the game ended).
|
|
/// </summary>
|
|
/// <param name="deadlineUtc">The instant the ceremony ends (players get teleported out).</param>
|
|
private async ValueTask EndCeremonyBroadcastLoopAsync(DateTime deadlineUtc)
|
|
{
|
|
try
|
|
{
|
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
|
|
while (DateTime.UtcNow < deadlineUtc
|
|
&& this.State == MiniGameState.Ended
|
|
&& await timer.WaitForNextTickAsync().ConfigureAwait(false))
|
|
{
|
|
await this.BroadcastHudStateAsync().ConfigureAwait(false);
|
|
await this.BroadcastScoreboardAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
this.Logger.LogError(ex, "{context}: Error in the end-of-event ceremony broadcast loop.", this);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the players which would receive the winner's Zen reward if the event ended right now, for unit
|
|
/// tests only (a full <see cref="GameEndedAsync"/> run needs a live <see cref="Player"/> with a working
|
|
/// <see cref="Player.TryAddMoney"/>/inventory, which isn't practical to construct in a fast unit test).
|
|
/// </summary>
|
|
/// <returns>The winning team's players, or an empty sequence if the event hasn't been decided yet.</returns>
|
|
internal IEnumerable<Player> GetRewardTargetsForTest()
|
|
=> this.GetWinner() == HeykelSavasiTeam.None ? Enumerable.Empty<Player>() : this.PlayersOf(this.GetWinner());
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Note: <see cref="MiniGameContext.OnObjectAddedToMapAsync"/> already subscribes every
|
|
/// <see cref="Destructible"/> added to <see cref="MiniGameContext.Map"/> to
|
|
/// <see cref="OnDestructibleDied"/> (see MiniGameContext.cs), so statues spawned via
|
|
/// <see cref="SpawnStatueAsync"/> are routed here automatically; this override must not
|
|
/// subscribe <c>Died</c> again.
|
|
/// </remarks>
|
|
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);
|
|
|
|
// Credit the statue break to the destroyer's character for the scoreboard.
|
|
this.RecordStatueBreak(e.KillerName);
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// ADAMU-CUSTOM (Heykel Savasi): guards spawned by <see cref="SpawnStatueAsync"/> are ordinary
|
|
/// <see cref="Monster"/>s, so their <c>Died</c> event is auto-subscribed to this handler by
|
|
/// <see cref="MiniGameContext.OnObjectAddedToMapAsync"/>. When a guard dies we decrement the living-guard
|
|
/// count of the statue it protected; only once that count reaches 0 does <see cref="IsStatueAttackBlocked"/>
|
|
/// let players damage the statue.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers, for tests only, that <paramref name="attacker"/> destroyed <paramref name="defender"/>'s
|
|
/// statue at <paramref name="index"/>, without triggering any spawn/warp/buff side effects.
|
|
/// </summary>
|
|
/// <param name="defender">The team whose statue was destroyed.</param>
|
|
/// <param name="index">The index (0..6) of the destroyed statue in <paramref name="defender"/>'s line.</param>
|
|
/// <param name="attacker">The team which destroyed the statue.</param>
|
|
internal void RegisterStatueDestroyedForTest(HeykelSavasiTeam defender, int index, HeykelSavasiTeam attacker)
|
|
=> this.HandleStatueDestroyed(defender, index, attacker, spawnNext: false);
|
|
|
|
/// <summary>
|
|
/// Gets the fixed sequence of 7 statue positions defending <paramref name="team"/>'s own line (attacked
|
|
/// by the opposing team). Index 0 is the outermost/first statue, index 6 is the final one.
|
|
/// </summary>
|
|
/// <param name="team">The defending team.</param>
|
|
/// <returns>The 7 statue positions of <paramref name="team"/>'s line, or an empty array for <see cref="HeykelSavasiTeam.None"/>.</returns>
|
|
/// <remarks>
|
|
/// This is deliberately kept in the GameLogic project (rather than
|
|
/// <c>Persistence.Initialization/VersionSeasonSix/Maps/HeykelSavasiMap.cs</c>, as originally sketched in
|
|
/// the task brief) because <c>MUnique.OpenMU.GameLogic</c> does not - and must not - reference
|
|
/// <c>MUnique.OpenMU.Persistence.Initialization</c>; the dependency only goes the other way.
|
|
/// </remarks>
|
|
internal static Point[] StatuePositions(HeykelSavasiTeam team)
|
|
{
|
|
return team switch
|
|
{
|
|
HeykelSavasiTeam.Red => RedStatuePositions,
|
|
HeykelSavasiTeam.Blue => BlueStatuePositions,
|
|
_ => Array.Empty<Point>(),
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines, in a pure way, whether joining <paramref name="team"/> 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 <see cref="MaxTeamDifference"/> after the prospective join.
|
|
/// </summary>
|
|
/// <param name="redCount">The current number of players in the red team.</param>
|
|
/// <param name="blueCount">The current number of players in the blue team.</param>
|
|
/// <param name="team">The team which a player wants to join.</param>
|
|
/// <returns><c>true</c> if the join is allowed; otherwise, <c>false</c>.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the opposing team of <paramref name="team"/>. <see cref="HeykelSavasiTeam.None"/> maps to itself.
|
|
/// </summary>
|
|
/// <param name="team">The team.</param>
|
|
/// <returns>The opposing team.</returns>
|
|
private static HeykelSavasiTeam Opponent(HeykelSavasiTeam team) => team switch
|
|
{
|
|
HeykelSavasiTeam.Red => HeykelSavasiTeam.Blue,
|
|
HeykelSavasiTeam.Blue => HeykelSavasiTeam.Red,
|
|
_ => HeykelSavasiTeam.None,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Pure state transition shared by the runtime death handler (<see cref="OnDestructibleDied"/>) and the
|
|
/// test-only <see cref="RegisterStatueDestroyedForTest"/>: records the attacker's progress and, once
|
|
/// <see cref="StatuesToBreak"/> statues have fallen, the winner. Spawning the next statue and applying
|
|
/// buffs are runtime-only side effects, triggered separately by the caller when <paramref name="spawnNext"/>
|
|
/// is <c>true</c> (see <see cref="OnStatueDestroyedSideEffectsAsync"/>).
|
|
/// </summary>
|
|
/// <param name="defender">The team whose statue was destroyed.</param>
|
|
/// <param name="index">The index (0..6) of the destroyed statue in <paramref name="defender"/>'s line.</param>
|
|
/// <param name="attacker">The team which destroyed the statue.</param>
|
|
/// <param name="spawnNext">
|
|
/// Unused by this pure method; documents, at call sites, whether the caller intends to trigger the
|
|
/// runtime spawn-next/buff/finish side effects afterwards (<see langword="true"/> from
|
|
/// <see cref="OnDestructibleDied"/>) or not (<see langword="false"/> from tests).
|
|
/// </param>
|
|
private void HandleStatueDestroyed(HeykelSavasiTeam defender, int index, HeykelSavasiTeam attacker, bool spawnNext)
|
|
{
|
|
_ = defender;
|
|
_ = spawnNext;
|
|
this._progress.RegisterDestroyed(index, attacker);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="StatuesToBreak"/> of the
|
|
/// opponent's statues. Statues are no longer spawned here - all of them are spawned up-front at battle
|
|
/// start (see <see cref="OnGameStartAsync"/>).
|
|
/// </summary>
|
|
/// <param name="attacker">The team which destroyed the statue.</param>
|
|
/// <param name="count">The attacker's running number of statues broken so far (1..<see cref="StatuesToBreak"/>).</param>
|
|
/// <param name="killerName">The character name of the player who dealt the killing blow.</param>
|
|
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 = $"Statue {count} destroyed by {killerName}!";
|
|
foreach (var player in this.PlayersOf(HeykelSavasiTeam.Red).Concat(this.PlayersOf(HeykelSavasiTeam.Blue)).ToList())
|
|
{
|
|
await player.InvokeViewPlugInAsync<MUnique.OpenMU.GameLogic.Views.IShowMessagePlugIn>(
|
|
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);
|
|
|
|
// Once BossSpawnAtTotalDestroyed statues (both teams combined) have fallen, spawn the 4 arena
|
|
// bosses exactly once. Interlocked guards against two near-simultaneous breaks both triggering.
|
|
if (this._progress.TotalDestroyed >= BossSpawnAtTotalDestroyed
|
|
&& Interlocked.CompareExchange(ref this._bossesSpawned, 1, 0) == 0)
|
|
{
|
|
await this.SpawnBossesAsync().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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Spawns <paramref name="defender"/>'s statue at <paramref name="index"/> in its line, plus 3 guard
|
|
/// mobs around it, using the same runtime-spawn API the map initializer itself uses
|
|
/// (<see cref="IMapInitializer.InitializeSpawnAsync"/>; see also Castle Siege's
|
|
/// <c>CastleSiegeEventPlugIn.SpawnCastleDefensesAsync</c> for the reference pattern). The spawned statue
|
|
/// is registered in <see cref="_statues"/> so <see cref="OnDestructibleDied"/> can attribute its death to
|
|
/// the correct defender/index; its <c>Died</c> event does not need to be subscribed here because
|
|
/// <see cref="MiniGameContext.OnObjectAddedToMapAsync"/> already does so for every
|
|
/// <see cref="Destructible"/> added to <see cref="MiniGameContext.Map"/>.
|
|
/// </summary>
|
|
/// <param name="defender">The team whose statue line is being extended.</param>
|
|
/// <param name="index">The index (0..6) of the statue to spawn in <paramref name="defender"/>'s line.</param>
|
|
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;
|
|
}
|
|
|
|
// Rename the in-game statue name to the event branding (runtime override, no DB change).
|
|
statueDefinition.Designation = "TvT Statue";
|
|
|
|
// 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,
|
|
|
|
// TEST PHASE: lower the statue's effective HP so it can be broken within a match (see
|
|
// StatueTestHealth). Overrides the definition's 3M health only for this spawn - no DB change.
|
|
MaximumHealthOverride = StatueTestHealth,
|
|
};
|
|
|
|
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);
|
|
|
|
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;
|
|
}
|
|
|
|
// Runtime overrides (no DB change): make the guards fully STATIONARY next to the statue and
|
|
// brand their in-game name. Guard 580 is only used by this event, so mutating its shared
|
|
// definition here is safe and idempotent (re-applied on every event start).
|
|
guardDefinition.IntelligenceTypeName = StationaryGuardIntelligenceTypeName;
|
|
guardDefinition.Designation = "TvT Guard";
|
|
|
|
var spawnedGuards = 0;
|
|
for (var i = 0; i < GuardsPerStatue; i++)
|
|
{
|
|
// Guards spawn on a WALKABLE tile in a small box around the statue. A box (not a fixed point)
|
|
// lets InitializeSpawnAsync retry to find a walkable tile near statues that sit against
|
|
// non-walkable terrain (see GuardSpawnRadius).
|
|
var guardSpawnArea = new MonsterSpawnArea
|
|
{
|
|
MonsterDefinition = guardDefinition,
|
|
Quantity = 1,
|
|
X1 = (byte)Math.Clamp(pos.X - GuardSpawnRadius, 1, 254),
|
|
X2 = (byte)Math.Clamp(pos.X + GuardSpawnRadius, 1, 254),
|
|
Y1 = (byte)Math.Clamp(pos.Y - GuardSpawnRadius, 1, 254),
|
|
Y2 = (byte)Math.Clamp(pos.Y + GuardSpawnRadius, 1, 254),
|
|
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;
|
|
spawnedGuards++;
|
|
}
|
|
}
|
|
|
|
// The statue becomes damageable only once all of its ACTUALLY-SPAWNED guards are dead. Using the
|
|
// real spawned count (not the constant) guarantees the gate can always reach 0 even if a guard
|
|
// still failed to spawn - otherwise that statue would be permanently unbreakable.
|
|
this._statueGuardsAlive[statue] = spawnedGuards;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
this.Logger.LogError(ex, "{context}: Error spawning statue for defender {defender}, index {index}.", this, defender, index);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Spawns the 4 arena bosses (see <see cref="BossSpawns"/>) at their fixed coordinates. Triggered once,
|
|
/// from <see cref="OnStatueDestroyedSideEffectsAsync"/>, after <see cref="BossSpawnAtTotalDestroyed"/>
|
|
/// statues (both teams combined) have been broken. Each boss spawns on a walkable tile in a small box
|
|
/// around its coordinate (same retry-tolerant approach as the guards). Drops are the bosses' own default
|
|
/// loot for now (to be tuned later).
|
|
/// </summary>
|
|
private async ValueTask SpawnBossesAsync()
|
|
{
|
|
try
|
|
{
|
|
// Broadcast the boss arrival to every event player (yellow center message).
|
|
const string bossMessage = "The arena bosses have appeared!";
|
|
foreach (var player in this.PlayersOf(HeykelSavasiTeam.Red).Concat(this.PlayersOf(HeykelSavasiTeam.Blue)).ToList())
|
|
{
|
|
await player.InvokeViewPlugInAsync<MUnique.OpenMU.GameLogic.Views.IShowMessagePlugIn>(
|
|
p => p.ShowMessageAsync(bossMessage, MUnique.OpenMU.Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false);
|
|
}
|
|
|
|
for (var i = 0; i < BossSpawns.Length; i++)
|
|
{
|
|
var (number, x, y) = BossSpawns[i];
|
|
var bossDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == number);
|
|
if (bossDefinition is null)
|
|
{
|
|
this.Logger.LogWarning("{context}: Boss monster definition {number} not found.", this, number);
|
|
continue;
|
|
}
|
|
|
|
// Bosses are Monsters -> need a walkable tile; use a small box so the spawn logic can retry.
|
|
var bossSpawnArea = new MonsterSpawnArea
|
|
{
|
|
MonsterDefinition = bossDefinition,
|
|
Quantity = 1,
|
|
X1 = (byte)Math.Clamp(x - GuardSpawnRadius, 1, 254),
|
|
X2 = (byte)Math.Clamp(x + GuardSpawnRadius, 1, 254),
|
|
Y1 = (byte)Math.Clamp(y - GuardSpawnRadius, 1, 254),
|
|
Y2 = (byte)Math.Clamp(y + GuardSpawnRadius, 1, 254),
|
|
Direction = Direction.South,
|
|
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
|
|
};
|
|
|
|
// Distinct spawn-index range for bosses (9000+), so they never collide with statues/guards.
|
|
await this._mapInitializer.InitializeSpawnAsync(9000 + i, this.Map, bossSpawnArea, this).ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
this.Logger.LogError(ex, "{context}: Error spawning arena bosses.", this);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies the class buff mapped to <paramref name="statueIndex"/> (see <see cref="StatueBuffEffectNumbers"/>)
|
|
/// to every player currently on <paramref name="team"/>. A no-op for the 7th statue (index 6, out of
|
|
/// range), which is the win condition rather than a buff trigger.
|
|
/// </summary>
|
|
/// <param name="team">The attacking team, whose players receive the buff.</param>
|
|
/// <param name="statueIndex">The index (0..6) of the statue that was just destroyed.</param>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a single, already-existing <see cref="MagicEffectDefinition"/> (looked up by
|
|
/// <paramref name="effectNumber"/>) to <paramref name="player"/>, for <see cref="BuffDuration"/>.
|
|
/// Mirrors the boost-construction pattern of <c>ApplyMagicEffectConsumeHandlerPlugIn.ConsumeItemAsyncCore</c>:
|
|
/// each of the definition's <c>PowerUpDefinitions</c> is turned into a boost element bound to the
|
|
/// player's own <see cref="Player.Attributes"/> system, so the buff scales off that player's stats.
|
|
/// </summary>
|
|
/// <param name="player">The player to buff.</param>
|
|
/// <param name="effectNumber">The <see cref="MagicEffectDefinition.Number"/> of the effect to apply.</param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-applies every buff <paramref name="player"/>'s team has already earned (one per statue broken so
|
|
/// far, indices [0..progress-1]) to <paramref name="player"/>. Intended for use on respawn (Task 5.3),
|
|
/// since the existing buffs are configured with <c>StopByDeath=true</c> and are cleared on death.
|
|
/// </summary>
|
|
/// <param name="player">The player to re-buff.</param>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="player">The player who (re)spawned.</param>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The <see cref="MonsterDefinition.Number"/> 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 (<see cref="IHeykelSavasiTeamRosterPlugIn"/>).
|
|
/// </summary>
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Transforms <paramref name="player"/> into the uniform <see cref="EventFormSkin"/> battle form, mirroring
|
|
/// the exact mechanism of <c>SkinChatCommandPlugIn</c> (the <c>/skin</c> command): it composes an
|
|
/// <see cref="AggregateType.AddRaw"/> element onto the player's <see cref="Stats.TransformationSkin"/>
|
|
/// attribute and sets the attribute value, which fires <c>Player.OnTransformationSkinChanged</c> and
|
|
/// re-broadcasts the player's appearance to observers.
|
|
/// </summary>
|
|
/// <param name="player">The player to transform.</param>
|
|
private ValueTask TransformAsync(Player player) => this.SetTransformationSkinAsync(player, EventFormSkin);
|
|
|
|
/// <summary>
|
|
/// Reverts <paramref name="player"/>'s transformation by resetting <see cref="Stats.TransformationSkin"/> to
|
|
/// 0 (no transformation), which restores the player's normal appearance (wings/mount/pet included). Mirrors
|
|
/// how <c>/skin 0</c> removes the added skin element.
|
|
/// </summary>
|
|
/// <param name="player">The player to revert.</param>
|
|
private ValueTask RemoveTransformAsync(Player player) => this.SetTransformationSkinAsync(player, 0);
|
|
|
|
/// <summary>
|
|
/// Sets <paramref name="player"/>'s <see cref="Stats.TransformationSkin"/> to <paramref name="skin"/> exactly
|
|
/// as <c>SkinChatCommandPlugIn</c> does: clear any previously composed elements, add a single
|
|
/// <see cref="AggregateType.AddRaw"/> 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.
|
|
/// </summary>
|
|
/// <param name="player">The player whose transformation skin to set.</param>
|
|
/// <param name="skin">The transformation skin number (0 = none/revert).</param>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Periodically (about once per second) broadcasts the Heykel Savasi HUD state
|
|
/// (<see cref="IHeykelSavasiHudStatePlugIn"/>) to every entered player, covering the registration phase
|
|
/// (<see cref="MiniGameState.Open"/>), the preparation countdown (<see cref="MiniGameState.Closed"/>), and
|
|
/// the battle itself (<see cref="MiniGameState.Playing"/>). Started once from the constructor via
|
|
/// <c>Task.Run</c> (mirroring <see cref="MiniGameContext"/>'s own pattern for its private game loop).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Stops as soon as <see cref="MiniGameContext.GameEndedToken"/> is cancelled, which happens exactly when
|
|
/// the game transitions to <see cref="MiniGameState.Ended"/> (see <c>MiniGameContext.StopAsync</c>, which
|
|
/// cancels the token and only then sets <see cref="MiniGameContext.State"/> to <see cref="MiniGameState.Ended"/>
|
|
/// before calling <see cref="GameEndedAsync"/>); the final "ended" broadcast is sent separately, from
|
|
/// <see cref="GameEndedAsync"/> itself, once <see cref="MiniGameContext.State"/> is actually <see cref="MiniGameState.Ended"/>.
|
|
/// </remarks>
|
|
/// <param name="cancellationToken">The token which is cancelled once the game ends.</param>
|
|
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);
|
|
await this.BroadcastScoreboardAsync().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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends the current Heykel Savasi HUD state (see <see cref="ComputeHudPhaseAndRemaining"/>) to every
|
|
/// entered player via <see cref="IHeykelSavasiHudStatePlugIn"/>. Called both periodically (see
|
|
/// <see cref="HudBroadcastLoopAsync"/>) and as a one-shot update from <see cref="TryJoinTeamAsync"/> (on a
|
|
/// successful join), <see cref="OnStatueDestroyedSideEffectsAsync"/> (on a statue break), and
|
|
/// <see cref="GameEndedAsync"/> (final state).
|
|
/// </summary>
|
|
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<IHeykelSavasiHudStatePlugIn>(p =>
|
|
p.UpdateHudStateAsync(phase, (byte)this.GetTeam(player), redCount, blueCount, redProgress, blueProgress, remainingSeconds))
|
|
.AsTask()).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends the current team roster (every participating player's network id and team) to every entered
|
|
/// player via <see cref="IHeykelSavasiTeamRosterPlugIn"/>, so the client can tint each nearby event player
|
|
/// red or blue. Broadcast on the same ~1s cadence as the HUD state (see <see cref="HudBroadcastLoopAsync"/>).
|
|
/// </summary>
|
|
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<IHeykelSavasiTeamRosterPlugIn>(p =>
|
|
p.UpdateTeamRosterAsync(entries))
|
|
.AsTask()).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends the per-character scoreboard to every entered player via <see cref="IHeykelSavasiScoreboardPlugIn"/>:
|
|
/// the top <see cref="ScoreboardMaxEntries"/> contributors ordered by total statue damage (descending).
|
|
/// Characters that dealt no statue damage are omitted. Broadcast on the same ~1s cadence as the HUD state.
|
|
/// </summary>
|
|
private async ValueTask BroadcastScoreboardAsync()
|
|
{
|
|
var entries = this._scores
|
|
.Select(kv => (kv.Key.Name, Score: kv.Value))
|
|
.Where(x => x.Score.Damage > 0)
|
|
.OrderByDescending(x => x.Score.Damage)
|
|
.ThenByDescending(x => x.Score.Statues)
|
|
.ThenByDescending(x => x.Score.Kills)
|
|
.Take(ScoreboardMaxEntries)
|
|
.Select(x => (
|
|
Name: x.Name ?? string.Empty,
|
|
Kills: (ushort)Math.Min(x.Score.Kills, ushort.MaxValue),
|
|
Statues: (byte)Math.Min(x.Score.Statues, byte.MaxValue),
|
|
Damage: (uint)Math.Min(Interlocked.Read(ref x.Score.Damage), uint.MaxValue)))
|
|
.ToList();
|
|
|
|
await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync<IHeykelSavasiScoreboardPlugIn>(p =>
|
|
p.UpdateScoreboardAsync(entries))
|
|
.AsTask()).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines the current HUD phase and the estimated number of seconds remaining in it, purely from
|
|
/// <see cref="MiniGameContext.State"/> and wall-clock timestamps recorded (lazily, on first observation) at
|
|
/// each relevant state transition. This is an approximation, not authoritative timing: <see cref="_createdAtUtc"/>
|
|
/// is set at construction time, which happens moments before <c>MiniGameContext.RunGameAsync</c>'s own
|
|
/// <c>Task.Run</c> starts counting down <see cref="MiniGameDefinition.EnterDuration"/>, and
|
|
/// <see cref="_closedAtUtc"/>/<see cref="_playingAtUtc"/> 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.
|
|
/// </summary>
|
|
/// <returns>The current phase (0-3, see <see cref="IHeykelSavasiHudStatePlugIn.UpdateHudStateAsync"/>) and the
|
|
/// estimated remaining seconds in that phase.</returns>
|
|
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). During the
|
|
// post-battle ceremony (State == Ended, deadline set) report the seconds left until the
|
|
// Lorencia teleport so the client can show a countdown and centre the final scoreboard.
|
|
return (3, this._endCeremonyDeadlineUtc is { } deadline
|
|
? ClampToUInt16Seconds(deadline - now)
|
|
: (ushort)0);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts <paramref name="remaining"/> to a <see cref="ushort"/> seconds count for the HUD packet,
|
|
/// clamping negative durations to 0.
|
|
/// </summary>
|
|
/// <param name="remaining">The remaining duration.</param>
|
|
private static ushort ClampToUInt16Seconds(TimeSpan remaining)
|
|
{
|
|
if (remaining <= TimeSpan.Zero)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var seconds = remaining.TotalSeconds;
|
|
return seconds >= ushort.MaxValue ? ushort.MaxValue : (ushort)seconds;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure, dependency-free tracker for statue-break progress and win detection. Extracted out of
|
|
/// <see cref="HeykelSavasiContext"/> so it can be unit-tested directly, without constructing a full
|
|
/// context (whose constructor requires a live <see cref="IGameContext"/>/<see cref="IMapInitializer"/>
|
|
/// and starts a background game loop via <c>Task.Run</c> - impractical in a fast unit test; see also
|
|
/// <see cref="IsJoinAllowed"/>, extracted for the same reason). Kept <c>internal</c> (rather than
|
|
/// <c>private</c>) specifically so <c>MUnique.OpenMU.Tests</c> can drive it directly.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// Mutable per-character contribution counters for the scoreboard. Fields (not properties) so they can be
|
|
/// updated atomically with <see cref="Interlocked"/> from concurrent hit/kill handlers.
|
|
/// </summary>
|
|
private sealed class PlayerScore
|
|
{
|
|
/// <summary>Number of enemy-team players killed.</summary>
|
|
public int Kills;
|
|
|
|
/// <summary>Number of statues broken (killing blow).</summary>
|
|
public int Statues;
|
|
|
|
/// <summary>Total damage dealt to statues.</summary>
|
|
public long Damage;
|
|
}
|
|
|
|
internal sealed class StatueProgressState
|
|
{
|
|
private readonly ConcurrentDictionary<HeykelSavasiTeam, int> _progress = new()
|
|
{
|
|
[HeykelSavasiTeam.Red] = 0,
|
|
[HeykelSavasiTeam.Blue] = 0,
|
|
};
|
|
|
|
private HeykelSavasiTeam _winner = HeykelSavasiTeam.None;
|
|
|
|
/// <summary>
|
|
/// Gets the number of the opponent's statues <paramref name="attacker"/> has destroyed so far.
|
|
/// </summary>
|
|
/// <param name="attacker">The attacking team.</param>
|
|
public int GetProgress(HeykelSavasiTeam attacker) => this._progress.TryGetValue(attacker, out var value) ? value : 0;
|
|
|
|
/// <summary>
|
|
/// Gets the total number of statues destroyed so far by both teams combined (used to trigger the
|
|
/// arena boss spawn once <see cref="BossSpawnAtTotalDestroyed"/> is reached).
|
|
/// </summary>
|
|
public int TotalDestroyed => this.GetProgress(HeykelSavasiTeam.Red) + this.GetProgress(HeykelSavasiTeam.Blue);
|
|
|
|
/// <summary>
|
|
/// Gets the winning team, or <see cref="HeykelSavasiTeam.None"/> if undecided.
|
|
/// </summary>
|
|
public HeykelSavasiTeam GetWinner() => this._winner;
|
|
|
|
/// <summary>
|
|
/// Registers that <paramref name="attacker"/> 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 <see cref="StatuesToBreak"/> the attacker is recorded as the winner.
|
|
/// Once a winner is set, further registrations are ignored.
|
|
/// </summary>
|
|
/// <param name="index">
|
|
/// 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.
|
|
/// </param>
|
|
/// <param name="attacker">The team which destroyed the statue.</param>
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|