diff --git a/src/GameLogic/MiniGames/HeykelSavasiContext.cs b/src/GameLogic/MiniGames/HeykelSavasiContext.cs
index 81a047d..620d707 100644
--- a/src/GameLogic/MiniGames/HeykelSavasiContext.cs
+++ b/src/GameLogic/MiniGames/HeykelSavasiContext.cs
@@ -248,6 +248,18 @@ public class HeykelSavasiContext : MiniGameContext
///
private int _bossesSpawned;
+ ///
+ /// Per-character contribution stats for the scoreboard ():
+ /// enemy kills, statues broken, and total damage dealt to statues. Keyed by the participating player;
+ /// fed by , and .
+ ///
+ private readonly ConcurrentDictionary _scores = new();
+
+ ///
+ /// The maximum number of characters shown on the scoreboard (top contributors by statue damage).
+ ///
+ private const int ScoreboardMaxEntries = 8;
+
///
/// Synchronizes the check-then-act sequence of , so that concurrent
/// join attempts cannot both pass the balance check before either of them reserves a slot.
@@ -448,6 +460,64 @@ public class HeykelSavasiContext : MiniGameContext
/// The player.
private void RemoveTeam(Player player) => this._teams.TryRemove(player, out _);
+ ///
+ /// Records that killed an enemy-team player (for the scoreboard kill count).
+ /// Called from Player.AfterKilledPlayerAsync when both players are in this event on opposing teams.
+ ///
+ /// The killing player.
+ public void RecordKill(Player killer)
+ {
+ if (killer is null)
+ {
+ return;
+ }
+
+ var score = this._scores.GetOrAdd(killer, _ => new PlayerScore());
+ Interlocked.Increment(ref score.Kills);
+ }
+
+ ///
+ /// Records that the character named broke a statue (dealt the killing blow),
+ /// for the scoreboard statue count. Called from , which only knows the
+ /// killer's name; it is matched to the participating player of that name.
+ ///
+ /// The character name of the player who broke the statue.
+ 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);
+ }
+
+ ///
+ /// Records dealt by to , for the
+ /// scoreboard damage ranking. Called from AttackableNpcBase.AttackByAsync for every hit that lands on a
+ /// statue of this event.
+ ///
+ /// The attacking player.
+ /// The statue NPC that was hit.
+ /// The health damage dealt by this hit.
+ 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);
+ }
+
///
/// 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);
}
+ ///
+ /// Sends the per-character scoreboard to every entered player via :
+ /// the top 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.
+ ///
+ 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(p =>
+ p.UpdateScoreboardAsync(entries))
+ .AsTask()).ConfigureAwait(false);
+ }
+
///
/// Determines the current HUD phase and the estimated number of seconds remaining in it, purely from
/// and wall-clock timestamps recorded (lazily, on first observation) at
@@ -1191,6 +1291,22 @@ public class HeykelSavasiContext : MiniGameContext
/// , extracted for the same reason). Kept internal (rather than
/// private) specifically so MUnique.OpenMU.Tests can drive it directly.
///
+ ///
+ /// Mutable per-character contribution counters for the scoreboard. Fields (not properties) so they can be
+ /// updated atomically with from concurrent hit/kill handlers.
+ ///
+ private sealed class PlayerScore
+ {
+ /// Number of enemy-team players killed.
+ public int Kills;
+
+ /// Number of statues broken (killing blow).
+ public int Statues;
+
+ /// Total damage dealt to statues.
+ public long Damage;
+ }
+
internal sealed class StatueProgressState
{
private readonly ConcurrentDictionary _progress = new()
diff --git a/src/GameLogic/NPC/AttackableNpcBase.cs b/src/GameLogic/NPC/AttackableNpcBase.cs
index 80c8979..79662dc 100644
--- a/src/GameLogic/NPC/AttackableNpcBase.cs
+++ b/src/GameLogic/NPC/AttackableNpcBase.cs
@@ -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)
diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs
index e9b731f..353002d 100644
--- a/src/GameLogic/Player.cs
+++ b/src/GameLogic/Player.cs
@@ -1890,6 +1890,21 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
/// The player killed.
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;
diff --git a/src/GameLogic/Views/MiniGames/IHeykelSavasiScoreboardPlugIn.cs b/src/GameLogic/Views/MiniGames/IHeykelSavasiScoreboardPlugIn.cs
new file mode 100644
index 0000000..f077167
--- /dev/null
+++ b/src/GameLogic/Views/MiniGames/IHeykelSavasiScoreboardPlugIn.cs
@@ -0,0 +1,22 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Views.MiniGames;
+
+///
+/// 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.
+///
+public interface IHeykelSavasiScoreboardPlugIn : IViewPlugIn
+{
+ ///
+ /// Sends the current scoreboard.
+ ///
+ ///
+ /// 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).
+ ///
+ ValueTask UpdateScoreboardAsync(IReadOnlyList<(string Name, ushort Kills, byte Statues, uint Damage)> entries);
+}
diff --git a/src/GameServer/RemoteView/MiniGames/HeykelSavasiScoreboardPlugIn.cs b/src/GameServer/RemoteView/MiniGames/HeykelSavasiScoreboardPlugIn.cs
new file mode 100644
index 0000000..3db193b
--- /dev/null
+++ b/src/GameServer/RemoteView/MiniGames/HeykelSavasiScoreboardPlugIn.cs
@@ -0,0 +1,66 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+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;
+
+///
+/// The default implementation of the which forwards the
+/// per-character scoreboard to the game client with a data packet
+/// (code 0xFD).
+///
+[PlugIn]
+[Guid("a3d8f1c2-6b4e-4f7a-8c2d-9e1b3a5f7d20")]
+public class HeykelSavasiScoreboardPlugIn : IHeykelSavasiScoreboardPlugIn
+{
+ private readonly RemotePlayer _player;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The player.
+ public HeykelSavasiScoreboardPlugIn(RemotePlayer player) => this._player = player;
+
+ ///
+ 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);
+ }
+}
diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs
index 9b3abf0..8ab4fe4 100644
--- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs
+++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs
@@ -31018,6 +31018,151 @@ public readonly struct PlayerTeam
set => this._data.Span[2] = value;
}
}
+}
+
+
+///
+/// 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.
+///
+public readonly struct HeykelSavasiScoreboard
+{
+ private readonly Memory _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public HeykelSavasiScoreboard(Memory data)
+ : this(data, true)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ /// If set to true, the header data is automatically initialized and written to the underlying span.
+ private HeykelSavasiScoreboard(Memory data, bool initialize)
+ {
+ this._data = data;
+ if (initialize)
+ {
+ var header = this.Header;
+ header.Type = HeaderType;
+ header.Code = Code;
+ header.Length = (byte)data.Length;
+ }
+ }
+
+ ///
+ /// Gets the header type of this data packet.
+ ///
+ public static byte HeaderType => 0xC1;
+
+ ///
+ /// Gets the operation code of this data packet.
+ ///
+ public static byte Code => 0xFD;
+
+ ///
+ /// Gets the header of this packet.
+ ///
+ public C1Header Header => new (this._data);
+
+ ///
+ /// Gets or sets the number of scoreboard entries which follow.
+ ///
+ public byte Count
+ {
+ get => this._data.Span[3];
+ set => this._data.Span[3] = value;
+ }
+
+ ///
+ /// Gets the of the specified index.
+ ///
+ public ScoreEntry this[int index] => new (this._data.Slice(4 + index * ScoreEntry.Length));
+
+ ///
+ /// Performs an implicit conversion from a Memory of bytes to a .
+ ///
+ /// The packet as span.
+ /// The packet as struct.
+ public static implicit operator HeykelSavasiScoreboard(Memory packet) => new (packet, false);
+
+ ///
+ /// Performs an implicit conversion from to a Memory of bytes.
+ ///
+ /// The packet as struct.
+ /// The packet as byte span.
+ public static implicit operator Memory(HeykelSavasiScoreboard packet) => packet._data;
+
+ ///
+ /// Calculates the size of the packet for the specified count of .
+ ///
+ /// The count of from which the size will be calculated.
+
+ public static int GetRequiredSize(int entriesCount) => entriesCount * ScoreEntry.Length + 4;
+
+
+///
+/// A character's TvT Event contribution: kills, statues broken, and total statue damage..
+///
+public readonly struct ScoreEntry
+{
+ private readonly Memory _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public ScoreEntry(Memory data)
+ {
+ this._data = data;
+ }
+
+ ///
+ /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed.
+ ///
+ public static int Length => 17;
+
+ ///
+ /// Gets or sets the character name.
+ ///
+ 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);
+ }
+
+ ///
+ /// Gets or sets number of enemy-team players this character killed.
+ ///
+ public ushort Kills
+ {
+ get => ReadUInt16BigEndian(this._data.Span[10..]);
+ set => WriteUInt16BigEndian(this._data.Span[10..], value);
+ }
+
+ ///
+ /// Gets or sets number of statues this character broke (dealt the killing blow to).
+ ///
+ public byte Statues
+ {
+ get => this._data.Span[12];
+ set => this._data.Span[12] = value;
+ }
+
+ ///
+ /// Gets or sets total damage this character dealt to statues.
+ ///
+ public uint Damage
+ {
+ get => ReadUInt32BigEndian(this._data.Span[13..]);
+ set => WriteUInt32BigEndian(this._data.Span[13..], value);
+ }
+}
}
///
/// Defines the role of a guild member.
diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml
index 48a6f7a..084c2cb 100644
--- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml
+++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml
@@ -11183,6 +11183,63 @@
+
+ C1Header
+ FD
+ HeykelSavasiScoreboard
+ ServerToClient
+ Periodically (about once every one to two seconds) while the TvT Event (Statue War) is being played.
+ The client updates its TvT Event scoreboard panel, listing the top contributing characters ordered by statue damage.
+
+
+ 3
+ Byte
+ Count
+ The number of scoreboard entries which follow.
+
+
+ 4
+ Structure[]
+ ScoreEntry
+ Entries
+ Count
+
+
+
+
+ ScoreEntry
+ A character's TvT Event contribution: kills, statues broken, and total statue damage.
+ 17
+
+
+ 0
+ String
+ Name
+ 10
+ The character name.
+
+
+ 10
+ ShortBigEndian
+ Kills
+ Number of enemy-team players this character killed.
+
+
+ 12
+ Byte
+ Statues
+ Number of statues this character broke (dealt the killing blow to).
+
+
+ 13
+ IntegerBigEndian
+ Damage
+ Total damage this character dealt to statues.
+
+
+
+
+
diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs
index 264a238..f054c49 100644
--- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs
+++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs
@@ -29382,3 +29382,148 @@ public readonly ref struct PlayerTeamRef
}
}
}
+
+
+///
+/// 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.
+///
+public readonly ref struct HeykelSavasiScoreboardRef
+{
+ private readonly Span _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public HeykelSavasiScoreboardRef(Span data)
+ : this(data, true)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ /// If set to true, the header data is automatically initialized and written to the underlying span.
+ private HeykelSavasiScoreboardRef(Span data, bool initialize)
+ {
+ this._data = data;
+ if (initialize)
+ {
+ var header = this.Header;
+ header.Type = HeaderType;
+ header.Code = Code;
+ header.Length = (byte)data.Length;
+ }
+ }
+
+ ///
+ /// Gets the header type of this data packet.
+ ///
+ public static byte HeaderType => 0xC1;
+
+ ///
+ /// Gets the operation code of this data packet.
+ ///
+ public static byte Code => 0xFD;
+
+ ///
+ /// Gets the header of this packet.
+ ///
+ public C1HeaderRef Header => new (this._data);
+
+ ///
+ /// Gets or sets the number of scoreboard entries which follow.
+ ///
+ public byte Count
+ {
+ get => this._data[3];
+ set => this._data[3] = value;
+ }
+
+ ///
+ /// Gets the of the specified index.
+ ///
+ public ScoreEntryRef this[int index] => new (this._data[(4 + index * ScoreEntryRef.Length)..]);
+
+ ///
+ /// Performs an implicit conversion from a Span of bytes to a .
+ ///
+ /// The packet as span.
+ /// The packet as struct.
+ public static implicit operator HeykelSavasiScoreboardRef(Span packet) => new (packet, false);
+
+ ///
+ /// Performs an implicit conversion from to a Span of bytes.
+ ///
+ /// The packet as struct.
+ /// The packet as byte span.
+ public static implicit operator Span(HeykelSavasiScoreboardRef packet) => packet._data;
+
+ ///
+ /// Calculates the size of the packet for the specified count of .
+ ///
+ /// The count of from which the size will be calculated.
+
+ public static int GetRequiredSize(int entriesCount) => entriesCount * ScoreEntryRef.Length + 4;
+
+
+///
+/// A character's TvT Event contribution: kills, statues broken, and total statue damage..
+///
+public readonly ref struct ScoreEntryRef
+{
+ private readonly Span _data;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The underlying data.
+ public ScoreEntryRef(Span data)
+ {
+ this._data = data;
+ }
+
+ ///
+ /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed.
+ ///
+ public static int Length => 17;
+
+ ///
+ /// Gets or sets the character name.
+ ///
+ 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);
+ }
+
+ ///
+ /// Gets or sets number of enemy-team players this character killed.
+ ///
+ public ushort Kills
+ {
+ get => ReadUInt16BigEndian(this._data[10..]);
+ set => WriteUInt16BigEndian(this._data[10..], value);
+ }
+
+ ///
+ /// Gets or sets number of statues this character broke (dealt the killing blow to).
+ ///
+ public byte Statues
+ {
+ get => this._data[12];
+ set => this._data[12] = value;
+ }
+
+ ///
+ /// Gets or sets total damage this character dealt to statues.
+ ///
+ public uint Damage
+ {
+ get => ReadUInt32BigEndian(this._data[13..]);
+ set => WriteUInt32BigEndian(this._data[13..], value);
+ }
+}
+}