From 2e3f9d7a044cb239beb5263a41c8f9dc6ffbf3b7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 18 Apr 2026 16:58:14 +0200 Subject: [PATCH] feat(combat): Phase E.4 AttackTargetRequest + combat notification pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the client-side combat loop: send attacks, receive server's damage broadcasts, maintain per-entity health state for HP bars + damage floaters. All atop Phase F.1's GameEvent dispatcher. Wire layer: - AttackTargetRequest (0x0008 C→S, inside 0xF7B1): targetGuid + powerLevel + accuracyLevel + attackHeight. 28-byte body. - GameEvents parsers for all combat notifications from r08 §4: - VictimNotification (0x01AC) — you got hit, full details - KillerNotification (0x01AD) — you killed X - AttackerNotification (0x01B1) — you hit X for Y (damage%) - DefenderNotification (0x01B2) — X hit you - EvasionAttackerNotification (0x01B3) — X evaded - EvasionDefenderNotification (0x01B4) — you evaded X - AttackDone (0x01A7) — attack sequence completed Core layer: - CombatState: per-entity health-percent cache + typed events (HealthChanged, DamageTaken, DamageDealtAccepted, EvadedIncoming, MissedOutgoing, AttackDone). Each event carries enough detail for the UI to render damage floaters, HP bars, and a combat log panel. Server is authoritative; client only mirrors state. The server computes damage (armor, resist, crit, hit-chance); the client only displays results. Predictive UI like "estimated damage at 0.75 power" still works via the existing CombatMath helper class that was in the scaffold (r02 §5 formulas). Tests (13 new): - AttackTargetRequest byte-exact wire encoding - VictimNotification / AttackerNotification / EvasionAttacker / AttackDone round-trip parse. - CombatState: UpdateHealth caches + fires, Victim fires DamageTaken, Attacker fires DamageDealt, Evasion routes to right event, AttackDone carries sequence+error, Clear resets cache. Build green, 544 tests pass (up from 532). Ref: r02 §7 (wire formats), r08 §4 (event payloads), ACE GameEvent*Notification.cs families. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Messages/AttackTargetRequest.cs | 62 +++++++++ src/AcDream.Core.Net/Messages/GameEvents.cs | 119 +++++++++++++++++ src/AcDream.Core/Combat/CombatState.cs | 126 ++++++++++++++++++ .../Messages/CombatEventTests.cs | 112 ++++++++++++++++ .../Combat/CombatStateTests.cs | 97 ++++++++++++++ 5 files changed, 516 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/AttackTargetRequest.cs create mode 100644 src/AcDream.Core/Combat/CombatState.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs create mode 100644 tests/AcDream.Core.Tests/Combat/CombatStateTests.cs 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); + } +}