acdream/src/AcDream.Core/Combat/CombatState.cs
Erik 8f627cce0e fix(D.5.3a): selected-object meter visual-gate fixes (name, gate, flash, magenta)
Visual gate against retail surfaced several fidelity gaps in the selected-object
strip; all fixed and user-confirmed. Faithful to gmToolbarUI::HandleSelectionChanged
(acclient_2013_pseudo_c.txt:198635) + RecvNotice_UpdateObjectHealth (:196213).

- UiMeter.DrawHBar: guard each slice on `id != 0` BEFORE resolve. resolve(0)
  returns the 1x1 magenta placeholder with a non-zero GL handle, so the single-
  image meter (caps id=0) was drawing 1px magenta caps at the bar's ends. The
  3-slice vitals meter (all ids set) was unaffected. (the magenta-lines bug)
- SelectedObjectController: meter visibility is now UpdateHealth-driven (shown when
  health is known for the selected guid — HasHealth at select or HealthChanged),
  not shown-on-select; brief green selection flash via Tick revert; overlay floated
  above the meter so the flash isn't hidden by the bar; name top-aligned into the
  bar sprite's black band (NameBandHeight) with the bar below.
- GameWindow.IsHealthBarTarget: gate the health bar on the server PWD bits
  BF_ATTACKABLE (0x10) | BF_PLAYER (0x8) — friendly/vendor NPCs and attackable
  Doors (Misc type) are name-only; players/monsters get the bar. Replaces the
  too-loose IsLiveCreatureTarget. Wired SelectedObjectController.Tick in OnUpdate.
- CombatState.HasHealth(guid): distinguishes a known health value from the 1.0
  default, so a re-selected already-assessed target shows its bar immediately.
- TextureCache.GetOrUploadRenderSurface: resolve the surface's DefaultPaletteId
  so paletted (P8/INDEX16) UI sprites decode instead of falling to magenta.
- ToolbarController.HiddenIds: also hide 0x100001A3 (stack-entry box) — retail
  hides it in HandleSelectionChanged; it was rendering as a stray black box.

Divergence register: AP-47 (meter-visible timing) retired (now faithful); AP-46
rewritten to the BF_ATTACKABLE/BF_PLAYER gate approximation. Full suite green
(2,688 passed / 4 skipped). User-confirmed: name on top, NPC name-only, monster
bar on assess, green flash, no magenta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:37:15 +02:00

174 lines
6.7 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();
public CombatMode CurrentMode { get; private set; } = CombatMode.NonCombat;
/// <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>The server accepted the attack and the power bar/animation can begin.</summary>
public event Action? AttackCommenced;
/// <summary>The locally requested or server-confirmed combat mode changed.</summary>
public event Action<CombatMode>? CombatModeChanged;
/// <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;
/// <summary>
/// True if an UpdateHealth (0x01C0) has ever been received for this guid — i.e. the
/// server has reported real health for it (via damage broadcast or a successful
/// assess/QueryHealth reply). Distinguishes a known value from the 1.0 default that
/// <see cref="GetHealthPercent"/> returns for unseen guids. Used by the selected-object
/// meter to gate visibility (retail shows the bar only once health is known —
/// gmToolbarUI::RecvNotice_UpdateObjectHealth, acclient_2013_pseudo_c.txt:196213).
/// </summary>
public bool HasHealth(uint guid) => _healthByGuid.ContainsKey(guid);
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 SetCombatMode(CombatMode mode)
{
if (CurrentMode == mode)
return;
CurrentMode = mode;
CombatModeChanged?.Invoke(mode);
}
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 OnCombatCommenceAttack()
=> AttackCommenced?.Invoke();
public void Clear() => _healthByGuid.Clear();
}