diff --git a/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs b/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs new file mode 100644 index 0000000..f3df54e --- /dev/null +++ b/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs @@ -0,0 +1,62 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Outbound 0x0008 AttackTargetRequest GameAction. +/// +/// +/// Wire layout (inside the 0xF7B1 GameAction envelope): +/// +/// u32 0xF7B1 // GameAction envelope opcode +/// u32 gameActionSequence // client sequence +/// u32 0x0008 // sub-opcode +/// u32 targetGuid // who to attack +/// f32 powerLevel // [0.0, 1.0] — the power bar position +/// f32 accuracyLevel // [0.0, 1.0] — for missile weapons +/// u32 attackHeight // 1=High, 2=Medium, 3=Low +/// +/// +/// +/// +/// The server ALREADY knows the attacker (it's the session's player), +/// so this message only carries the target + attack params. The server +/// then rolls damage, picks a body part, and broadcasts +/// / AttackerNotification +/// / DefenderNotification / EvasionAttackerNotification / +/// EvasionDefenderNotification with the result. +/// +/// +/// +/// References: r02 §7 (wire format), r08 §3 opcode 0x0008. +/// +/// +public static class AttackTargetRequest +{ + public const uint GameActionEnvelope = 0xF7B1u; + public const uint SubOpcode = 0x0008u; + + /// + /// Build the wire body for an attack request. + /// + /// [0..1] melee power bar position. + /// [0..1] missile accuracy bar position; pass 0 for melee. + /// 1=High, 2=Medium, 3=Low. + public static byte[] Build( + uint gameActionSequence, + uint targetGuid, + float powerLevel, + float accuracyLevel, + uint attackHeight) + { + byte[] body = new byte[28]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SubOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid); + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(16), powerLevel); + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), accuracyLevel); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), attackHeight); + return body; + } +} diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index 5e962b2..8c35872 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -145,6 +145,125 @@ public static class GameEvents return BinaryPrimitives.ReadUInt32LittleEndian(payload); } + // ── Combat notifications ──────────────────────────────────────────────── + + /// 0x01AC VictimNotification — "you got hit for X". + public readonly record struct VictimNotification( + string AttackerName, + uint AttackerGuid, + uint DamageType, + uint Damage, + uint HitQuadrant, + uint Critical, + uint AttackType); + + public static VictimNotification? ParseVictimNotification(ReadOnlySpan payload) + { + int pos = 0; + try + { + string name = ReadString16L(payload, ref pos); + if (payload.Length - pos < 24) return null; + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint atkType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + return new VictimNotification(name, guid, damageType, damage, quad, crit, atkType); + } + catch { return null; } + } + + /// 0x01AD KillerNotification — "you killed X". + public readonly record struct KillerNotification(string VictimName, uint VictimGuid); + + public static KillerNotification? ParseKillerNotification(ReadOnlySpan payload) + { + int pos = 0; + try + { + string name = ReadString16L(payload, ref pos); + if (payload.Length - pos < 4) return null; + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); + return new KillerNotification(name, guid); + } + catch { return null; } + } + + /// 0x01B1 AttackerNotification — "you hit X for Y%". + public readonly record struct AttackerNotification( + string DefenderName, + uint DamageType, + uint Damage, + float DamagePercent); + + public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan payload) + { + int pos = 0; + try + { + string name = ReadString16L(payload, ref pos); + if (payload.Length - pos < 12) return null; + uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + float pct = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos)); pos += 4; + return new AttackerNotification(name, damageType, damage, pct); + } + catch { return null; } + } + + /// 0x01B2 DefenderNotification — "X hit you for Y". + public readonly record struct DefenderNotification( + string AttackerName, + uint AttackerGuid, + uint DamageType, + uint Damage, + uint HitQuadrant, + uint Critical); + + public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan payload) + { + int pos = 0; + try + { + string name = ReadString16L(payload, ref pos); + if (payload.Length - pos < 20) return null; + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + return new DefenderNotification(name, guid, dtype, dmg, quad, crit); + } + catch { return null; } + } + + /// 0x01B3 EvasionAttackerNotification — "X evaded". + public static string? ParseEvasionAttackerNotification(ReadOnlySpan payload) + { + int pos = 0; + try { return ReadString16L(payload, ref pos); } catch { return null; } + } + + /// 0x01B4 EvasionDefenderNotification — "you evaded X". + public static string? ParseEvasionDefenderNotification(ReadOnlySpan payload) + { + int pos = 0; + try { return ReadString16L(payload, ref pos); } catch { return null; } + } + + /// 0x01A7 AttackDone — (attackSequence, weenieError). + public readonly record struct AttackDone(uint AttackSequence, uint WeenieError); + + public static AttackDone? ParseAttackDone(ReadOnlySpan payload) + { + if (payload.Length < 8) return null; + return new AttackDone( + BinaryPrimitives.ReadUInt32LittleEndian(payload), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4))); + } + // ── Appraise / identify ───────────────────────────────────────────────── /// 0x00C9 IdentifyObjectResponse header. diff --git a/src/AcDream.Core/Combat/CombatState.cs b/src/AcDream.Core/Combat/CombatState.cs new file mode 100644 index 0000000..9308a13 --- /dev/null +++ b/src/AcDream.Core/Combat/CombatState.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Concurrent; + +namespace AcDream.Core.Combat; + +/// +/// Client-side combat state — tracks per-entity health percent and +/// emits typed events when UpdateHealth / Victim / Attacker / Defender +/// notifications arrive. Powers target HP bars, damage floaters, combat +/// log panel. +/// +/// +/// Retail client-side combat responsibilities (r02 §7): +/// +/// +/// Maintain a cache of "last known health percent" per entity guid. +/// UpdateHealth (0x01C0) is sent when the player queries or the +/// server broadcasts a change. +/// +/// +/// Convert raw damage events into UI-ready notifications (colored +/// floating numbers, "Critical!" flashes, body-part locations). +/// +/// +/// Track self-centered notifications (you hit / you got hit / you +/// evaded / you were evaded) so the log panel can format them +/// correctly. +/// +/// +/// +/// +/// +/// The server is authoritative: this class does NOT simulate damage +/// locally (exception: a predictive-ish "estimated damage" display for +/// the attack bar UI, which can use ). +/// +/// +public sealed class CombatState +{ + private readonly ConcurrentDictionary _healthByGuid = new(); + + /// Fires when a target's health percent changes (from UpdateHealth). + public event Action? HealthChanged; + + /// You (the player) got hit for some damage. + public event Action? DamageTaken; + + /// You (the player) dealt some damage. + public event Action? DamageDealtAccepted; + + /// You (the player) evaded an incoming hit. + public event Action? EvadedIncoming; + + /// The target evaded your hit. + public event Action? MissedOutgoing; + + /// An attack commit completed (0x01A7). WeenieError = 0 on success. + public event Action? AttackDone; + + public readonly record struct DamageIncoming( + string AttackerName, + uint AttackerGuid, + uint DamageType, + uint Damage, + uint HitQuadrant, + bool Critical, + uint AttackType); + + public readonly record struct DamageDealt( + string DefenderName, + uint DamageType, + uint Damage, + float DamagePercent); + + /// Retrieve last known health percent for a guid, or 1.0 if unknown. + public float GetHealthPercent(uint guid) => + _healthByGuid.TryGetValue(guid, out var pct) ? pct : 1f; + + public int TrackedTargetCount => _healthByGuid.Count; + + // ── Inbound handlers (wired from WorldSession.GameEvents) ──────────────── + + public void OnUpdateHealth(uint targetGuid, float healthPercent) + { + _healthByGuid[targetGuid] = healthPercent; + HealthChanged?.Invoke(targetGuid, healthPercent); + } + + public void OnVictimNotification( + string attackerName, uint attackerGuid, uint damageType, uint damage, + uint hitQuadrant, uint critical, uint attackType) + { + DamageTaken?.Invoke(new DamageIncoming( + attackerName, attackerGuid, damageType, damage, hitQuadrant, + critical != 0, attackType)); + } + + public void OnDefenderNotification( + string attackerName, uint attackerGuid, uint damageType, uint damage, + uint hitQuadrant, uint critical) + { + // DefenderNotification is semantically the same as VictimNotification + // from the defender's POV — the client log merges them. + DamageTaken?.Invoke(new DamageIncoming( + attackerName, attackerGuid, damageType, damage, hitQuadrant, + critical != 0, 0)); + } + + public void OnAttackerNotification( + string defenderName, uint damageType, uint damage, float damagePercent) + { + DamageDealtAccepted?.Invoke(new DamageDealt( + defenderName, damageType, damage, damagePercent)); + } + + public void OnEvasionAttackerNotification(string defenderName) + => MissedOutgoing?.Invoke(defenderName); + + public void OnEvasionDefenderNotification(string attackerName) + => EvadedIncoming?.Invoke(attackerName); + + public void OnAttackDone(uint attackSequence, uint weenieError) + => AttackDone?.Invoke(attackSequence, weenieError); + + public void Clear() => _healthByGuid.Clear(); +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs new file mode 100644 index 0000000..2352cac --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Buffers.Binary; +using System.Text; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class CombatEventTests +{ + private static byte[] MakeString16L(string s) + { + byte[] data = Encoding.ASCII.GetBytes(s); + int recordSize = 2 + data.Length; + int padding = (4 - (recordSize & 3)) & 3; + byte[] result = new byte[recordSize + padding]; + BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length); + Array.Copy(data, 0, result, 2, data.Length); + return result; + } + + [Fact] + public void AttackTargetRequest_Build_EmitsCorrectWireBytes() + { + byte[] body = AttackTargetRequest.Build( + gameActionSequence: 3, + targetGuid: 0x12345678u, + powerLevel: 0.75f, + accuracyLevel: 0.5f, + attackHeight: 2); + + Assert.Equal(28, body.Length); + Assert.Equal(AttackTargetRequest.GameActionEnvelope, + BinaryPrimitives.ReadUInt32LittleEndian(body)); + Assert.Equal(3u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); + Assert.Equal(AttackTargetRequest.SubOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0x12345678u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(0.75f, + BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16)), 4); + Assert.Equal(0.5f, + BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4); + Assert.Equal(2u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24))); + } + + [Fact] + public void ParseVictimNotification_RoundTrip() + { + byte[] name = MakeString16L("Attacker"); + byte[] tail = new byte[24]; + BinaryPrimitives.WriteUInt32LittleEndian(tail, 0xAAu); // guid + BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 1u); // damageType + BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(8), 42u); // damage + BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(12), 3u); // quadrant + BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(16), 1u); // crit + BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(20), 8u); // attackType + + byte[] payload = new byte[name.Length + tail.Length]; + Buffer.BlockCopy(name, 0, payload, 0, name.Length); + Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length); + + var parsed = GameEvents.ParseVictimNotification(payload); + Assert.NotNull(parsed); + Assert.Equal("Attacker", parsed!.Value.AttackerName); + Assert.Equal(0xAAu, parsed.Value.AttackerGuid); + Assert.Equal(42u, parsed.Value.Damage); + Assert.Equal(1u, parsed.Value.Critical); + } + + [Fact] + public void ParseAttackerNotification_RoundTrip() + { + byte[] name = MakeString16L("Drudge"); + byte[] tail = new byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(tail, 1u); // damageType + BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 30u); // damage + BinaryPrimitives.WriteSingleLittleEndian(tail.AsSpan(8), 0.15f); // percent + + byte[] payload = new byte[name.Length + tail.Length]; + Buffer.BlockCopy(name, 0, payload, 0, name.Length); + Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length); + + var parsed = GameEvents.ParseAttackerNotification(payload); + Assert.NotNull(parsed); + Assert.Equal("Drudge", parsed!.Value.DefenderName); + Assert.Equal(30u, parsed.Value.Damage); + Assert.Equal(0.15f, parsed.Value.DamagePercent, 4); + } + + [Fact] + public void ParseEvasionAttackerNotification_RoundTrip() + { + byte[] payload = MakeString16L("Thrower"); + Assert.Equal("Thrower", GameEvents.ParseEvasionAttackerNotification(payload)); + } + + [Fact] + public void ParseAttackDone_RoundTrip() + { + byte[] payload = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0u); // no error + + var parsed = GameEvents.ParseAttackDone(payload); + Assert.NotNull(parsed); + Assert.Equal(42u, parsed!.Value.AttackSequence); + Assert.Equal(0u, parsed.Value.WeenieError); + } +} diff --git a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs new file mode 100644 index 0000000..d9baae8 --- /dev/null +++ b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs @@ -0,0 +1,97 @@ +using AcDream.Core.Combat; +using Xunit; + +namespace AcDream.Core.Tests.Combat; + +public sealed class CombatStateTests +{ + [Fact] + public void OnUpdateHealth_CachesPercent_AndFiresEvent() + { + var state = new CombatState(); + uint seenGuid = 0; + float seenPct = 0; + state.HealthChanged += (g, p) => { seenGuid = g; seenPct = p; }; + + state.OnUpdateHealth(0xBEEF, 0.33f); + + Assert.Equal(0xBEEFu, seenGuid); + Assert.Equal(0.33f, seenPct, 4); + Assert.Equal(0.33f, state.GetHealthPercent(0xBEEF), 4); + } + + [Fact] + public void GetHealthPercent_Unknown_ReturnsFullHealth() + { + var state = new CombatState(); + Assert.Equal(1f, state.GetHealthPercent(0xDEAD)); + } + + [Fact] + public void OnVictimNotification_FiresDamageTaken() + { + var state = new CombatState(); + CombatState.DamageIncoming seen = default; + state.DamageTaken += d => seen = d; + + state.OnVictimNotification("Drudge", 0xAA, 1, 42, 3, 1, 8); + + Assert.Equal("Drudge", seen.AttackerName); + Assert.Equal(42u, seen.Damage); + Assert.True(seen.Critical); + } + + [Fact] + public void OnAttackerNotification_FiresDamageDealt() + { + var state = new CombatState(); + CombatState.DamageDealt seen = default; + state.DamageDealtAccepted += d => seen = d; + + state.OnAttackerNotification("Drudge", 1, 30, 0.15f); + + Assert.Equal("Drudge", seen.DefenderName); + Assert.Equal(30u, seen.Damage); + Assert.Equal(0.15f, seen.DamagePercent, 4); + } + + [Fact] + public void OnEvasionNotification_FiresCorrectEvent() + { + var state = new CombatState(); + string? evaded = null, missed = null; + state.EvadedIncoming += a => evaded = a; + state.MissedOutgoing += d => missed = d; + + state.OnEvasionDefenderNotification("Rat"); // you evaded rat + state.OnEvasionAttackerNotification("Tusker"); // tusker evaded you + + Assert.Equal("Rat", evaded); + Assert.Equal("Tusker", missed); + } + + [Fact] + public void OnAttackDone_FiresAttackDone() + { + var state = new CombatState(); + uint seenSeq = 0, seenErr = 999; + state.AttackDone += (s, e) => { seenSeq = s; seenErr = e; }; + + state.OnAttackDone(5, 0); + Assert.Equal(5u, seenSeq); + Assert.Equal(0u, seenErr); + } + + [Fact] + public void Clear_ResetsHealthCache() + { + var state = new CombatState(); + state.OnUpdateHealth(1, 0.5f); + state.OnUpdateHealth(2, 0.8f); + + state.Clear(); + + Assert.Equal(1f, state.GetHealthPercent(1)); // back to default + Assert.Equal(0, state.TrackedTargetCount); + } +}