acdream/src/AcDream.Core/Combat/CombatState.cs
Erik 567078803f docs(issues): #8/#9/#11 filed; #10 wired (KillerNotification)
Files four new issues created by the 2026-04-25 PDB-discovery sprint:
  #8  (DONE 2026-04-25) — pdb-extract tool, shipped 69d884a
  #9  (OPEN)            — function-map address-correction sweep
                          (Phase E will close)
  #10 (DONE 2026-04-25) — wire KillerNotification (0x01AD); orphan
                          parser at GameEvents.ParseKillerNotification
                          existed but was never registered. This commit
                          adds CombatState.OnKillerNotification +
                          KillLanded event, registers the dispatcher
                          handler, and adds a regression test.
  #11 (OPEN)            — spell metadata loader (spells.csv → SpellTable)
                          (Phase F will close)

Code change is minimal — three lines of dispatch + a 12-line
CombatState method with a typed event for future killfeed UI.

818 tests passing (+1 KillerNotification).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:39:47 +02:00

144 lines
5.5 KiB
C#

using System;
using System.Collections.Concurrent;
namespace AcDream.Core.Combat;
/// <summary>
/// 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.
///
/// <para>
/// Retail client-side combat responsibilities (r02 §7):
/// <list type="bullet">
/// <item><description>
/// 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.
/// </description></item>
/// <item><description>
/// Convert raw damage events into UI-ready notifications (colored
/// floating numbers, "Critical!" flashes, body-part locations).
/// </description></item>
/// <item><description>
/// Track self-centered notifications (you hit / you got hit / you
/// evaded / you were evaded) so the log panel can format them
/// correctly.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// 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 <see cref="CombatMath"/>).
/// </para>
/// </summary>
public sealed class CombatState
{
private readonly ConcurrentDictionary<uint, float> _healthByGuid = new();
/// <summary>Fires when a target's health percent changes (from UpdateHealth).</summary>
public event Action<uint /*guid*/, float /*percent*/>? HealthChanged;
/// <summary>You (the player) got hit for some damage.</summary>
public event Action<DamageIncoming>? DamageTaken;
/// <summary>You (the player) dealt some damage.</summary>
public event Action<DamageDealt>? DamageDealtAccepted;
/// <summary>You (the player) evaded an incoming hit.</summary>
public event Action<string>? EvadedIncoming;
/// <summary>The target evaded your hit.</summary>
public event Action<string>? MissedOutgoing;
/// <summary>An attack commit completed (0x01A7). WeenieError = 0 on success.</summary>
public event Action<uint /*attackSeq*/, uint /*weenieError*/>? AttackDone;
/// <summary>
/// Fires when the server confirms the player landed a killing blow
/// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is
/// the victim's display name + their server GUID. Used by killfeed UI
/// (future panel) and any plugin scoring kill counts.
/// </summary>
public event Action<string /*victimName*/, uint /*victimGuid*/>? KillLanded;
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);
/// <summary>Retrieve last known health percent for a guid, or 1.0 if unknown.</summary>
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);
/// <summary>
/// Server confirmation that the player landed a killing blow on a
/// target. Wire source: GameEvent <c>KillerNotification (0x01AD)</c>
/// — the parser at <c>GameEvents.ParseKillerNotification</c> shipped
/// alongside victim/defender notifications but was never registered
/// for dispatch until 2026-04-25 (per ISSUES.md #10).
/// </summary>
public void OnKillerNotification(string victimName, uint victimGuid)
=> KillLanded?.Invoke(victimName, victimGuid);
public void OnEvasionDefenderNotification(string attackerName)
=> EvadedIncoming?.Invoke(attackerName);
public void OnAttackDone(uint attackSequence, uint weenieError)
=> AttackDone?.Invoke(attackSequence, weenieError);
public void Clear() => _healthByGuid.Clear();
}