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

@@ -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);
}
}
}