feat(tvt): per-character scoreboard (kills, statues, statue damage)

Server: new 0xFD HeykelSavasiScoreboard packet + view plugin; HeykelSavasiContext tracks per-player
kills (Player.AfterKilledPlayerAsync hook), statues broken (OnDestructibleDied), and statue damage
(AttackableNpcBase.AttackByAsync hook); broadcast top-8 by damage (desc) every ~1s, omitting zero-damage
characters.
This commit is contained in:
Acentech Dev
2026-07-21 16:34:15 +03:00
parent 586590e06f
commit 538189ea7c
8 changed files with 573 additions and 0 deletions

View File

@@ -248,6 +248,18 @@ public class HeykelSavasiContext : MiniGameContext
/// </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 = 8;
/// <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.
@@ -448,6 +460,64 @@ public class HeykelSavasiContext : MiniGameContext
/// <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
@@ -591,6 +661,9 @@ public class HeykelSavasiContext : MiniGameContext
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);
@@ -1083,6 +1156,7 @@ public class HeykelSavasiContext : MiniGameContext
{
await this.BroadcastHudStateAsync().ConfigureAwait(false);
await this.BroadcastTeamRosterAsync().ConfigureAwait(false);
await this.BroadcastScoreboardAsync().ConfigureAwait(false);
}
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false));
}
@@ -1133,6 +1207,32 @@ public class HeykelSavasiContext : MiniGameContext
.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
@@ -1191,6 +1291,22 @@ public class HeykelSavasiContext : MiniGameContext
/// <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()