feat(combat): Phase E.4 AttackTargetRequest + combat notification pipeline

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>
This commit is contained in:
Erik 2026-04-18 16:58:14 +02:00
parent 2561f5599f
commit 2e3f9d7a04
5 changed files with 516 additions and 0 deletions

View file

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