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