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