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()

View File

@@ -142,6 +142,13 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable
{
await player.ApplyMaceMasteryStunEffectAsync(this).ConfigureAwait(false);
}
// ADAMU-CUSTOM: TvT Event -> record per-character statue damage for the event scoreboard.
if (this.Definition.ObjectKind == NpcObjectKind.Destructible
&& player.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelDamageGame)
{
heykelDamageGame.RecordStatueDamage(player, this, (uint)hitInfo.HealthDamage);
}
}
if (attacker as IPlayerSurrogate is { } playerSurrogate)

View File

@@ -1890,6 +1890,21 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
/// <param name="killedPlayer">The player killed.</param>
internal async ValueTask AfterKilledPlayerAsync(Player killedPlayer)
{
// ADAMU-CUSTOM: TvT Event -> credit the killer with an enemy-team kill for the event scoreboard.
// Done up front, before the PK-state early-returns below, so it counts regardless of hero state.
if (this.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelKillGame
&& ReferenceEquals(killedPlayer.CurrentMiniGame, this.CurrentMiniGame))
{
var killerTeam = heykelKillGame.GetTeam(this);
var victimTeam = heykelKillGame.GetTeam(killedPlayer);
if (killerTeam != victimTeam
&& killerTeam != MiniGames.HeykelSavasiTeam.None
&& victimTeam != MiniGames.HeykelSavasiTeam.None)
{
heykelKillGame.RecordKill(this);
}
}
if (this.DuelRoom?.State == DuelState.DuelStarted)
{
return;

View File

@@ -0,0 +1,22 @@
// <copyright file="IHeykelSavasiScoreboardPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Views.MiniGames;
/// <summary>
/// Interface of a view whose implementation sends the TvT Event (Statue War) per-character scoreboard: the
/// top contributing characters ordered by statue damage, each with its kill count, statues broken, and total
/// statue damage.
/// </summary>
public interface IHeykelSavasiScoreboardPlugIn : IViewPlugIn
{
/// <summary>
/// Sends the current scoreboard.
/// </summary>
/// <param name="entries">
/// The scoreboard entries, already ordered (highest statue damage first) and capped by the caller.
/// Each entry is (character name, kills, statues broken, total statue damage).
/// </param>
ValueTask UpdateScoreboardAsync(IReadOnlyList<(string Name, ushort Kills, byte Statues, uint Damage)> entries);
}

View File

@@ -0,0 +1,66 @@
// <copyright file="HeykelSavasiScoreboardPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameServer.RemoteView.MiniGames;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Views.MiniGames;
using MUnique.OpenMU.Network;
using MUnique.OpenMU.Network.Packets.ServerToClient;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// The default implementation of the <see cref="IHeykelSavasiScoreboardPlugIn"/> which forwards the
/// per-character scoreboard to the game client with a <see cref="HeykelSavasiScoreboardRef"/> data packet
/// (code 0xFD).
/// </summary>
[PlugIn]
[Guid("a3d8f1c2-6b4e-4f7a-8c2d-9e1b3a5f7d20")]
public class HeykelSavasiScoreboardPlugIn : IHeykelSavasiScoreboardPlugIn
{
private readonly RemotePlayer _player;
/// <summary>
/// Initializes a new instance of the <see cref="HeykelSavasiScoreboardPlugIn"/> class.
/// </summary>
/// <param name="player">The player.</param>
public HeykelSavasiScoreboardPlugIn(RemotePlayer player) => this._player = player;
/// <inheritdoc/>
public async ValueTask UpdateScoreboardAsync(IReadOnlyList<(string Name, ushort Kills, byte Statues, uint Damage)> entries)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
// A byte Count field caps the scoreboard at 255 entries; the caller sends far fewer (top contributors).
var count = entries.Count > byte.MaxValue ? byte.MaxValue : entries.Count;
int Write()
{
var size = HeykelSavasiScoreboardRef.GetRequiredSize(count);
var span = connection.Output.GetSpan(size)[..size];
var packet = new HeykelSavasiScoreboardRef(span)
{
Count = (byte)count,
};
for (var i = 0; i < count; i++)
{
var block = packet[i];
block.Name = entries[i].Name;
block.Kills = entries[i].Kills;
block.Statues = entries[i].Statues;
block.Damage = entries[i].Damage;
}
return size;
}
await connection.SendAsync(Write).ConfigureAwait(false);
}
}

View File

@@ -31018,6 +31018,151 @@ public readonly struct PlayerTeam
set => this._data.Span[2] = value;
}
}
}
/// <summary>
/// Is sent by the server when: Periodically (about once every one to two seconds) while the TvT Event (Statue War) is being played.
/// Causes reaction on client side: The client updates its TvT Event scoreboard panel, listing the top contributing characters ordered by statue damage.
/// </summary>
public readonly struct HeykelSavasiScoreboard
{
private readonly Memory<byte> _data;
/// <summary>
/// Initializes a new instance of the <see cref="HeykelSavasiScoreboard"/> struct.
/// </summary>
/// <param name="data">The underlying data.</param>
public HeykelSavasiScoreboard(Memory<byte> data)
: this(data, true)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="HeykelSavasiScoreboard"/> struct.
/// </summary>
/// <param name="data">The underlying data.</param>
/// <param name="initialize">If set to <c>true</c>, the header data is automatically initialized and written to the underlying span.</param>
private HeykelSavasiScoreboard(Memory<byte> data, bool initialize)
{
this._data = data;
if (initialize)
{
var header = this.Header;
header.Type = HeaderType;
header.Code = Code;
header.Length = (byte)data.Length;
}
}
/// <summary>
/// Gets the header type of this data packet.
/// </summary>
public static byte HeaderType => 0xC1;
/// <summary>
/// Gets the operation code of this data packet.
/// </summary>
public static byte Code => 0xFD;
/// <summary>
/// Gets the header of this packet.
/// </summary>
public C1Header Header => new (this._data);
/// <summary>
/// Gets or sets the number of scoreboard entries which follow.
/// </summary>
public byte Count
{
get => this._data.Span[3];
set => this._data.Span[3] = value;
}
/// <summary>
/// Gets the <see cref="ScoreEntry"/> of the specified index.
/// </summary>
public ScoreEntry this[int index] => new (this._data.Slice(4 + index * ScoreEntry.Length));
/// <summary>
/// Performs an implicit conversion from a Memory of bytes to a <see cref="HeykelSavasiScoreboard"/>.
/// </summary>
/// <param name="packet">The packet as span.</param>
/// <returns>The packet as struct.</returns>
public static implicit operator HeykelSavasiScoreboard(Memory<byte> packet) => new (packet, false);
/// <summary>
/// Performs an implicit conversion from <see cref="HeykelSavasiScoreboard"/> to a Memory of bytes.
/// </summary>
/// <param name="packet">The packet as struct.</param>
/// <returns>The packet as byte span.</returns>
public static implicit operator Memory<byte>(HeykelSavasiScoreboard packet) => packet._data;
/// <summary>
/// Calculates the size of the packet for the specified count of <see cref="ScoreEntry"/>.
/// </summary>
/// <param name="entriesCount">The count of <see cref="ScoreEntry"/> from which the size will be calculated.</param>
public static int GetRequiredSize(int entriesCount) => entriesCount * ScoreEntry.Length + 4;
/// <summary>
/// A character's TvT Event contribution: kills, statues broken, and total statue damage..
/// </summary>
public readonly struct ScoreEntry
{
private readonly Memory<byte> _data;
/// <summary>
/// Initializes a new instance of the <see cref="ScoreEntry"/> struct.
/// </summary>
/// <param name="data">The underlying data.</param>
public ScoreEntry(Memory<byte> data)
{
this._data = data;
}
/// <summary>
/// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed.
/// </summary>
public static int Length => 17;
/// <summary>
/// Gets or sets the character name.
/// </summary>
public string Name
{
get => this._data.Span.ExtractString(0, 10, System.Text.Encoding.UTF8);
set => this._data.Slice(0, 10).Span.WriteString(value, System.Text.Encoding.UTF8);
}
/// <summary>
/// Gets or sets number of enemy-team players this character killed.
/// </summary>
public ushort Kills
{
get => ReadUInt16BigEndian(this._data.Span[10..]);
set => WriteUInt16BigEndian(this._data.Span[10..], value);
}
/// <summary>
/// Gets or sets number of statues this character broke (dealt the killing blow to).
/// </summary>
public byte Statues
{
get => this._data.Span[12];
set => this._data.Span[12] = value;
}
/// <summary>
/// Gets or sets total damage this character dealt to statues.
/// </summary>
public uint Damage
{
get => ReadUInt32BigEndian(this._data.Span[13..]);
set => WriteUInt32BigEndian(this._data.Span[13..], value);
}
}
}
/// <summary>
/// Defines the role of a guild member.

View File

@@ -11183,6 +11183,63 @@
</Structure>
</Structures>
</Packet>
<Packet>
<HeaderType>C1Header</HeaderType>
<Code>FD</Code>
<Name>HeykelSavasiScoreboard</Name>
<Direction>ServerToClient</Direction>
<SentWhen>Periodically (about once every one to two seconds) while the TvT Event (Statue War) is being played.</SentWhen>
<CausedReaction>The client updates its TvT Event scoreboard panel, listing the top contributing characters ordered by statue damage.</CausedReaction>
<Fields>
<Field>
<Index>3</Index>
<Type>Byte</Type>
<Name>Count</Name>
<Description>The number of scoreboard entries which follow.</Description>
</Field>
<Field>
<Index>4</Index>
<Type>Structure[]</Type>
<TypeName>ScoreEntry</TypeName>
<Name>Entries</Name>
<ItemCountField>Count</ItemCountField>
</Field>
</Fields>
<Structures>
<Structure>
<Name>ScoreEntry</Name>
<Description>A character's TvT Event contribution: kills, statues broken, and total statue damage.</Description>
<Length>17</Length>
<Fields>
<Field>
<Index>0</Index>
<Type>String</Type>
<Name>Name</Name>
<Length>10</Length>
<Description>The character name.</Description>
</Field>
<Field>
<Index>10</Index>
<Type>ShortBigEndian</Type>
<Name>Kills</Name>
<Description>Number of enemy-team players this character killed.</Description>
</Field>
<Field>
<Index>12</Index>
<Type>Byte</Type>
<Name>Statues</Name>
<Description>Number of statues this character broke (dealt the killing blow to).</Description>
</Field>
<Field>
<Index>13</Index>
<Type>IntegerBigEndian</Type>
<Name>Damage</Name>
<Description>Total damage this character dealt to statues.</Description>
</Field>
</Fields>
</Structure>
</Structures>
</Packet>
</Packets>
<Enums>
<Enum>

View File

@@ -29382,3 +29382,148 @@ public readonly ref struct PlayerTeamRef
}
}
}
/// <summary>
/// Is sent by the server when: Periodically (about once every one to two seconds) while the TvT Event (Statue War) is being played.
/// Causes reaction on client side: The client updates its TvT Event scoreboard panel, listing the top contributing characters ordered by statue damage.
/// </summary>
public readonly ref struct HeykelSavasiScoreboardRef
{
private readonly Span<byte> _data;
/// <summary>
/// Initializes a new instance of the <see cref="HeykelSavasiScoreboardRef"/> struct.
/// </summary>
/// <param name="data">The underlying data.</param>
public HeykelSavasiScoreboardRef(Span<byte> data)
: this(data, true)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="HeykelSavasiScoreboardRef"/> struct.
/// </summary>
/// <param name="data">The underlying data.</param>
/// <param name="initialize">If set to <c>true</c>, the header data is automatically initialized and written to the underlying span.</param>
private HeykelSavasiScoreboardRef(Span<byte> data, bool initialize)
{
this._data = data;
if (initialize)
{
var header = this.Header;
header.Type = HeaderType;
header.Code = Code;
header.Length = (byte)data.Length;
}
}
/// <summary>
/// Gets the header type of this data packet.
/// </summary>
public static byte HeaderType => 0xC1;
/// <summary>
/// Gets the operation code of this data packet.
/// </summary>
public static byte Code => 0xFD;
/// <summary>
/// Gets the header of this packet.
/// </summary>
public C1HeaderRef Header => new (this._data);
/// <summary>
/// Gets or sets the number of scoreboard entries which follow.
/// </summary>
public byte Count
{
get => this._data[3];
set => this._data[3] = value;
}
/// <summary>
/// Gets the <see cref="ScoreEntryRef"/> of the specified index.
/// </summary>
public ScoreEntryRef this[int index] => new (this._data[(4 + index * ScoreEntryRef.Length)..]);
/// <summary>
/// Performs an implicit conversion from a Span of bytes to a <see cref="HeykelSavasiScoreboard"/>.
/// </summary>
/// <param name="packet">The packet as span.</param>
/// <returns>The packet as struct.</returns>
public static implicit operator HeykelSavasiScoreboardRef(Span<byte> packet) => new (packet, false);
/// <summary>
/// Performs an implicit conversion from <see cref="HeykelSavasiScoreboard"/> to a Span of bytes.
/// </summary>
/// <param name="packet">The packet as struct.</param>
/// <returns>The packet as byte span.</returns>
public static implicit operator Span<byte>(HeykelSavasiScoreboardRef packet) => packet._data;
/// <summary>
/// Calculates the size of the packet for the specified count of <see cref="ScoreEntryRef"/>.
/// </summary>
/// <param name="entriesCount">The count of <see cref="ScoreEntryRef"/> from which the size will be calculated.</param>
public static int GetRequiredSize(int entriesCount) => entriesCount * ScoreEntryRef.Length + 4;
/// <summary>
/// A character's TvT Event contribution: kills, statues broken, and total statue damage..
/// </summary>
public readonly ref struct ScoreEntryRef
{
private readonly Span<byte> _data;
/// <summary>
/// Initializes a new instance of the <see cref="ScoreEntryRef"/> struct.
/// </summary>
/// <param name="data">The underlying data.</param>
public ScoreEntryRef(Span<byte> data)
{
this._data = data;
}
/// <summary>
/// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed.
/// </summary>
public static int Length => 17;
/// <summary>
/// Gets or sets the character name.
/// </summary>
public string Name
{
get => this._data.ExtractString(0, 10, System.Text.Encoding.UTF8);
set => this._data.Slice(0, 10).WriteString(value, System.Text.Encoding.UTF8);
}
/// <summary>
/// Gets or sets number of enemy-team players this character killed.
/// </summary>
public ushort Kills
{
get => ReadUInt16BigEndian(this._data[10..]);
set => WriteUInt16BigEndian(this._data[10..], value);
}
/// <summary>
/// Gets or sets number of statues this character broke (dealt the killing blow to).
/// </summary>
public byte Statues
{
get => this._data[12];
set => this._data[12] = value;
}
/// <summary>
/// Gets or sets total damage this character dealt to statues.
/// </summary>
public uint Damage
{
get => ReadUInt32BigEndian(this._data[13..]);
set => WriteUInt32BigEndian(this._data[13..], value);
}
}
}