diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index a4515cc..93fd62e 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -156,22 +156,20 @@ public static class GameEventWiring dispatcher.Register(GameEventType.VictimNotification, e => { var p = GameEvents.ParseVictimNotification(e.Payload.Span); - if (p is not null) combat.OnVictimNotification( - p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType, - p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical, p.Value.AttackType); + if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error); }); dispatcher.Register(GameEventType.DefenderNotification, e => { var p = GameEvents.ParseDefenderNotification(e.Payload.Span); if (p is not null) combat.OnDefenderNotification( - p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType, + p.Value.AttackerName, 0u, p.Value.DamageType, p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical); }); dispatcher.Register(GameEventType.AttackerNotification, e => { var p = GameEvents.ParseAttackerNotification(e.Payload.Span); if (p is not null) combat.OnAttackerNotification( - p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, p.Value.DamagePercent); + p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, (float)p.Value.HealthPercent); }); dispatcher.Register(GameEventType.EvasionAttackerNotification, e => { @@ -188,12 +186,15 @@ public static class GameEventWiring var p = GameEvents.ParseAttackDone(e.Payload.Span); if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError); }); + dispatcher.Register(GameEventType.CombatCommenceAttack, e => + { + if (GameEvents.ParseCombatCommenceAttack(e.Payload.Span)) + combat.OnCombatCommenceAttack(); + }); dispatcher.Register(GameEventType.KillerNotification, e => { - // ISSUES.md #10 — orphan parser, never registered before. The - // server fires this after a player lands a killing blow. var p = GameEvents.ParseKillerNotification(e.Payload.Span); - if (p is not null) combat.OnKillerNotification(p.Value.VictimName, p.Value.VictimGuid); + if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Info); }); // ── Spells ──────────────────────────────────────────────── diff --git a/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs b/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs index f3df54e..d4fc1f5 100644 --- a/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs +++ b/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs @@ -3,60 +3,79 @@ using System.Buffers.Binary; namespace AcDream.Core.Net.Messages; /// -/// Outbound 0x0008 AttackTargetRequest GameAction. +/// Outbound combat attack GameActions. +/// +/// Retail/ACE use distinct payloads for melee and missile: /// -/// -/// 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 0x0008 // TargetedMeleeAttack +/// u32 targetGuid /// u32 attackHeight // 1=High, 2=Medium, 3=Low +/// f32 powerLevel // [0.0, 1.0] +/// +/// u32 0xF7B1 +/// u32 gameActionSequence +/// u32 0x000A // TargetedMissileAttack +/// u32 targetGuid +/// u32 attackHeight +/// f32 accuracyLevel // [0.0, 1.0] /// -/// /// -/// -/// 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. -/// +/// References: CM_Combat::Event_TargetedMeleeAttack 0x006A9C10, +/// CM_Combat::Event_TargetedMissileAttack 0x006A9D60, ACE +/// GameActionTargetedMeleeAttack/GameActionTargetedMissileAttack, and +/// holtburger protocol game_action.rs. /// public static class AttackTargetRequest { public const uint GameActionEnvelope = 0xF7B1u; - public const uint SubOpcode = 0x0008u; + public const uint TargetedMeleeAttackOpcode = 0x0008u; + public const uint TargetedMissileAttackOpcode = 0x000Au; + public const uint CancelAttackOpcode = 0x01B7u; - /// - /// 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( + /// Build the wire body for a targeted melee attack. + public static byte[] BuildMelee( uint gameActionSequence, uint targetGuid, - float powerLevel, - float accuracyLevel, - uint attackHeight) + uint attackHeight, + float powerLevel) { - byte[] body = new byte[28]; + byte[] body = new byte[24]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SubOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedMeleeAttackOpcode); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid); - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(16), powerLevel); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), attackHeight); + BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), powerLevel); + return body; + } + + /// Build the wire body for a targeted missile attack. + public static byte[] BuildMissile( + uint gameActionSequence, + uint targetGuid, + uint attackHeight, + float accuracyLevel) + { + byte[] body = new byte[24]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedMissileAttackOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), attackHeight); BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), accuracyLevel); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), attackHeight); + return body; + } + + /// Build the wire body for cancelling an active attack request. + public static byte[] BuildCancel(uint gameActionSequence) + { + byte[] body = new byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), CancelAttackOpcode); return body; } } diff --git a/src/AcDream.Core.Net/Messages/CharacterActions.cs b/src/AcDream.Core.Net/Messages/CharacterActions.cs index 0da9505..4abbbc3 100644 --- a/src/AcDream.Core.Net/Messages/CharacterActions.cs +++ b/src/AcDream.Core.Net/Messages/CharacterActions.cs @@ -22,9 +22,17 @@ public static class CharacterActions public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode + [Flags] public enum CombatMode : uint { - Undef = 0, NonCombat = 1, Melee = 2, Missile = 3, Magic = 4, Peaceful = 5, + Undef = 0, + NonCombat = 0x01, + Melee = 0x02, + Missile = 0x04, + Magic = 0x08, + + ValidCombat = NonCombat | Melee | Missile | Magic, + CombatCombat = Melee | Missile | Magic, } /// Spend XP to raise an attribute (Strength, Endurance, etc). diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index 6889140..d913162 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -147,56 +147,34 @@ public static class GameEvents // ── 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); + /// 0x01AC VictimNotification - death message for the victim. + public readonly record struct VictimNotification(string DeathMessage); 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); - } + try { return new VictimNotification(ReadString16L(payload, ref pos)); } catch { return null; } } - /// 0x01AD KillerNotification — "you killed X". - public readonly record struct KillerNotification(string VictimName, uint VictimGuid); + /// 0x01AD KillerNotification - death message for the killer. + public readonly record struct KillerNotification(string DeathMessage); 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); - } + try { return new KillerNotification(ReadString16L(payload, ref pos)); } catch { return null; } } - /// 0x01B1 AttackerNotification — "you hit X for Y%". + /// 0x01B1 AttackerNotification - "you hit X". public readonly record struct AttackerNotification( string DefenderName, uint DamageType, + double HealthPercent, uint Damage, - float DamagePercent); + uint Critical, + ulong AttackConditions); public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan payload) { @@ -204,23 +182,26 @@ public static class GameEvents 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); + if (payload.Length - pos < 28) return null; + uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8; + uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8; + return new AttackerNotification(name, damageType, pct, damage, crit, cond); } catch { return null; } } - /// 0x01B2 DefenderNotification — "X hit you for Y". + /// 0x01B2 DefenderNotification - "X hit you". public readonly record struct DefenderNotification( string AttackerName, - uint AttackerGuid, uint DamageType, + double HealthPercent, uint Damage, uint HitQuadrant, - uint Critical); + uint Critical, + ulong AttackConditions); public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan payload) { @@ -228,40 +209,42 @@ public static class GameEvents 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); + if (payload.Length - pos < 32) return null; + uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; + double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8; + 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; + ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8; + return new DefenderNotification(name, dtype, pct, dmg, quad, crit, cond); } catch { return null; } } - /// 0x01B3 EvasionAttackerNotification — "X evaded". + /// 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". + /// 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). + /// 0x01B8 CombatCommenceAttack - empty payload. + public static bool ParseCombatCommenceAttack(ReadOnlySpan payload) => payload.Length == 0; + + /// 0x01A7 AttackDone - single WeenieError value. 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))); + if (payload.Length < 4) return null; + return new AttackDone(0u, BinaryPrimitives.ReadUInt32LittleEndian(payload)); } // ── Spell enchantments ────────────────────────────────────────────────── diff --git a/src/AcDream.Core/Combat/CombatState.cs b/src/AcDream.Core/Combat/CombatState.cs index 93a5094..7f6772a 100644 --- a/src/AcDream.Core/Combat/CombatState.cs +++ b/src/AcDream.Core/Combat/CombatState.cs @@ -57,6 +57,9 @@ public sealed class CombatState /// An attack commit completed (0x01A7). WeenieError = 0 on success. public event Action? AttackDone; + /// The server accepted the attack and the power bar/animation can begin. + public event Action? AttackCommenced; + /// /// Fires when the server confirms the player landed a killing blow /// (GameEvent KillerNotification (0x01AD)). Event payload is @@ -140,5 +143,8 @@ public sealed class CombatState public void OnAttackDone(uint attackSequence, uint weenieError) => AttackDone?.Invoke(attackSequence, weenieError); + public void OnCombatCommenceAttack() + => AttackCommenced?.Invoke(); + public void Clear() => _healthByGuid.Clear(); } diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index d32936c..f740efb 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -241,28 +241,32 @@ public sealed class GameEventWiringTests } [Fact] - public void WireAll_KillerNotification_FiresKillLandedOnCombatState() + public void WireAll_KillerNotification_AppendsCombatLine() { - // Issue #10 — orphan parser at GameEvents.ParseKillerNotification - // existed but was never registered for dispatch until 2026-04-25. - // Now wired: 0x01AD lands on CombatState.OnKillerNotification + - // fires the KillLanded event. - var (d, _, combat, _, _) = MakeAll(); - string? gotVictimName = null; - uint gotVictimGuid = 0; - combat.KillLanded += (name, guid) => { gotVictimName = name; gotVictimGuid = guid; }; - - // Wire shape: string16L victimName + u32 victimGuid - byte[] nameBytes = MakeString16L("Drudge"); - byte[] payload = new byte[nameBytes.Length + 4]; - Array.Copy(nameBytes, payload, nameBytes.Length); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(nameBytes.Length), 0x80001234u); + var (d, _, _, _, chat) = MakeAll(); + byte[] payload = MakeString16L("You killed the drudge!"); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload)); d.Dispatch(env!.Value); - Assert.Equal("Drudge", gotVictimName); - Assert.Equal(0x80001234u, gotVictimGuid); + Assert.Equal(1, chat.Count); + var entry = chat.Snapshot()[0]; + Assert.Equal(ChatKind.Combat, entry.Kind); + Assert.Equal(CombatLineKind.Info, entry.CombatKind); + Assert.Equal("You killed the drudge!", entry.Text); + } + + [Fact] + public void WireAll_CombatCommenceAttack_FiresCombatStateEvent() + { + var (d, _, combat, _, _) = MakeAll(); + bool commenced = false; + combat.AttackCommenced += () => commenced = true; + + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.CombatCommenceAttack, Array.Empty())); + d.Dispatch(env!.Value); + + Assert.True(commenced); } [Fact] diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs index 979aeaa..9419461 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs @@ -57,4 +57,13 @@ public sealed class CharacterActionsTests Assert.Equal(2u, // Melee = 2 BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); } + + [Fact] + public void CombatMode_UsesRetailAceBitValues() + { + Assert.Equal(1u, (uint)CharacterActions.CombatMode.NonCombat); + Assert.Equal(2u, (uint)CharacterActions.CombatMode.Melee); + Assert.Equal(4u, (uint)CharacterActions.CombatMode.Missile); + Assert.Equal(8u, (uint)CharacterActions.CombatMode.Magic); + } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs index 2352cac..b10b308 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs @@ -1,6 +1,5 @@ using System; using System.Buffers.Binary; -using System.Text; using AcDream.Core.Net.Messages; using Xunit; @@ -8,105 +7,140 @@ 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() + public void AttackTargetRequest_BuildMelee_EmitsRetailWireBytes() { - byte[] body = AttackTargetRequest.Build( + byte[] body = AttackTargetRequest.BuildMelee( gameActionSequence: 3, targetGuid: 0x12345678u, - powerLevel: 0.75f, - accuracyLevel: 0.5f, - attackHeight: 2); + attackHeight: 2, + powerLevel: 0.75f); - Assert.Equal(28, body.Length); + Assert.Equal(24, body.Length); Assert.Equal(AttackTargetRequest.GameActionEnvelope, BinaryPrimitives.ReadUInt32LittleEndian(body)); Assert.Equal(3u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); - Assert.Equal(AttackTargetRequest.SubOpcode, + Assert.Equal(AttackTargetRequest.TargetedMeleeAttackOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); Assert.Equal(0x12345678u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(2u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); Assert.Equal(0.75f, - BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16)), 4); + BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4); + } + + [Fact] + public void AttackTargetRequest_BuildMissile_EmitsRetailWireBytes() + { + byte[] body = AttackTargetRequest.BuildMissile( + gameActionSequence: 4, + targetGuid: 0x87654321u, + attackHeight: 1, + accuracyLevel: 0.5f); + + Assert.Equal(24, body.Length); + Assert.Equal(AttackTargetRequest.TargetedMissileAttackOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0x87654321u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(1u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); Assert.Equal(0.5f, BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4); - Assert.Equal(2u, - BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24))); } [Fact] - public void ParseVictimNotification_RoundTrip() + public void AttackTargetRequest_BuildCancel_HasNoPayload() { - 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[] body = AttackTargetRequest.BuildCancel(gameActionSequence: 5); - 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); + Assert.Equal(12, body.Length); + Assert.Equal(AttackTargetRequest.CancelAttackOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + } + + [Fact] + public void ParseAttackDone_HoltburgerFixture() + { + var env = ParseFixture("B0F700000000000000000000A701000036000000"); + + Assert.Equal(GameEventType.AttackDone, env.EventType); + var parsed = GameEvents.ParseAttackDone(env.Payload.Span); - 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(0u, parsed!.Value.AttackSequence); + Assert.Equal(0x36u, parsed.Value.WeenieError); + } + + [Fact] + public void ParseAttackerNotification_HoltburgerFixture() + { + var env = ParseFixture("B0F700000000000001000000B10100000E0044727564676520526176656E657201000000000000000000D03F25000000010000000600000000000000"); + + var parsed = GameEvents.ParseAttackerNotification(env.Payload.Span); + + Assert.NotNull(parsed); + Assert.Equal("Drudge Ravener", parsed!.Value.DefenderName); + Assert.Equal(1u, parsed.Value.DamageType); + Assert.Equal(0.25, parsed.Value.HealthPercent, 6); + Assert.Equal(37u, parsed.Value.Damage); Assert.Equal(1u, parsed.Value.Critical); + Assert.Equal(6ul, parsed.Value.AttackConditions); } [Fact] - public void ParseAttackerNotification_RoundTrip() + public void ParseDefenderNotification_HoltburgerFixture() { - 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 + var env = ParseFixture("B0F700000000000002000000B20100000A0042616E6465726C696E6710000000000000000000C03F1200000001000000000000000800000000000000"); - 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.ParseDefenderNotification(env.Payload.Span); - 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); + Assert.Equal("Banderling", parsed!.Value.AttackerName); + Assert.Equal(0x10u, parsed.Value.DamageType); + Assert.Equal(0.125, parsed.Value.HealthPercent, 6); + Assert.Equal(18u, parsed.Value.Damage); + Assert.Equal(1u, parsed.Value.HitQuadrant); + Assert.Equal(0u, parsed.Value.Critical); + Assert.Equal(8ul, parsed.Value.AttackConditions); } [Fact] - public void ParseEvasionAttackerNotification_RoundTrip() + public void ParseEvasionNotifications_HoltburgerFixtures() { - byte[] payload = MakeString16L("Thrower"); - Assert.Equal("Thrower", GameEvents.ParseEvasionAttackerNotification(payload)); + var attacker = ParseFixture("B0F700000000000003000000B301000008004D6F7373776172740000"); + var defender = ParseFixture("B0F700000000000004000000B401000004004D6974650000"); + + Assert.Equal("Mosswart", GameEvents.ParseEvasionAttackerNotification(attacker.Payload.Span)); + Assert.Equal("Mite", GameEvents.ParseEvasionDefenderNotification(defender.Payload.Span)); } [Fact] - public void ParseAttackDone_RoundTrip() + public void ParseCombatCommenceAttack_HoltburgerFixture() { - byte[] payload = new byte[8]; - BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0u); // no error + var env = ParseFixture("B0F700000000000005000000B8010000"); - var parsed = GameEvents.ParseAttackDone(payload); - Assert.NotNull(parsed); - Assert.Equal(42u, parsed!.Value.AttackSequence); - Assert.Equal(0u, parsed.Value.WeenieError); + Assert.Equal(GameEventType.CombatCommenceAttack, env.EventType); + Assert.True(GameEvents.ParseCombatCommenceAttack(env.Payload.Span)); + } + + [Fact] + public void ParseDeathNotifications_HoltburgerFixtures() + { + var victim = ParseFixture("B0F700000000000006000000AC0100000E00596F752068617665206469656421"); + var killer = ParseFixture("B0F700000000000007000000AD0100001600596F75206B696C6C6564207468652064727564676521"); + + Assert.Equal("You have died!", GameEvents.ParseVictimNotification(victim.Payload.Span)?.DeathMessage); + Assert.Equal("You killed the drudge!", GameEvents.ParseKillerNotification(killer.Payload.Span)?.DeathMessage); + } + + private static GameEventEnvelope ParseFixture(string hex) + { + byte[] body = Convert.FromHexString(hex); + var env = GameEventEnvelope.TryParse(body); + Assert.NotNull(env); + return env.Value; } }