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) <noreply@anthropic.com>
112 lines
4.4 KiB
C#
112 lines
4.4 KiB
C#
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);
|
|
}
|
|
}
|