diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index cfefdc3..ac201d6 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -310,6 +310,11 @@ public sealed class GameWindow : IDisposable
// back to NullCommandBus.Instance.
private AcDream.UI.Abstractions.LiveCommandBus? _commandBus;
+ // Phase I.7 — bridges CombatState's typed events into ChatLog as
+ // retail-faithful "You hit ..." / "... evaded your attack." lines.
+ // Disposable; lives for the duration of the live session.
+ private AcDream.Core.Chat.CombatChatTranslator? _combatChatTranslator;
+
// Phase G.1-G.2 world lighting/time state.
public readonly AcDream.Core.World.WorldTimeService WorldTime =
new AcDream.Core.World.WorldTimeService(
@@ -1224,6 +1229,13 @@ public sealed class GameWindow : IDisposable
_liveSession.GameEvents, Items, Combat, SpellBook, Chat, LocalPlayer,
TurbineChat);
+ // Phase I.7: subscribe to CombatState events and emit
+ // retail-faithful "You hit X for Y damage" chat lines into
+ // the unified ChatLog. The translator owns the wording
+ // (templates ported from holtburger chat.rs:221-308); the
+ // panel renders combat entries via TextColored.
+ _combatChatTranslator = new AcDream.Core.Chat.CombatChatTranslator(Combat, Chat);
+
// Phase H.1: feed inbound HearSpeech into the chat log.
_liveSession.SpeechHeard += speech =>
Chat.OnLocalSpeech(
@@ -1347,6 +1359,8 @@ public sealed class GameWindow : IDisposable
catch (Exception ex)
{
Console.WriteLine($"live: session failed: {ex.Message}");
+ _combatChatTranslator?.Dispose();
+ _combatChatTranslator = null;
_liveSession?.Dispose();
_liveSession = null;
}
@@ -4931,6 +4945,9 @@ public sealed class GameWindow : IDisposable
// state. The worker may still be processing a load job that references
// _dats; Dispose cancels the token and waits up to 2s for the thread.
_streamer?.Dispose();
+ // Phase I.7: unsubscribe combat → chat translator before the
+ // session it depends on goes away.
+ _combatChatTranslator?.Dispose();
_liveSession?.Dispose();
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_staticMesh?.Dispose();
diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs
index 86e33ea..06e31ce 100644
--- a/src/AcDream.Core/Chat/ChatLog.cs
+++ b/src/AcDream.Core/Chat/ChatLog.cs
@@ -166,6 +166,30 @@ public sealed class ChatLog
ChannelId: 0));
}
+ ///
+ /// Phase I.7: combat-translator emits a pre-formatted line. The
+ /// translator () subscribes to
+ /// events and renders the retail
+ /// template (e.g. "You hit Mosswart for 12 slashing damage (54.0%)
+ /// Critical hit.") and decorates the entry with a
+ /// so the panel can color the
+ /// line. Maps to holtburger's info().combat() /
+ /// warning().combat() / error().combat() tag flow at
+ /// chat.rs:221-308.
+ ///
+ public void OnCombatLine(string text, Combat.CombatLineKind kind = Combat.CombatLineKind.Info)
+ {
+ Append(new ChatEntry(
+ Kind: ChatKind.Combat,
+ Sender: "",
+ Text: text,
+ SenderGuid: 0,
+ ChannelId: 0)
+ {
+ CombatKind = kind,
+ });
+ }
+
/// Echo the player's own outbound message after local send.
public void OnSelfSent(ChatKind kind, string text, string targetOrChannel = "")
{
@@ -201,6 +225,14 @@ public enum ChatKind
Popup,
Emote,
SoulEmote,
+ ///
+ /// Phase I.7: a combat feedback line emitted by
+ /// from
+ /// events. The accompanying field
+ /// drives panel coloring (info / warning / error per holtburger
+ /// chat.rs:221-308).
+ ///
+ Combat,
}
public readonly record struct ChatEntry(
@@ -211,4 +243,11 @@ public readonly record struct ChatEntry(
uint ChannelId)
{
public DateTime Received { get; init; } = DateTime.UtcNow;
+
+ ///
+ /// Phase I.7: severity bucket for
+ /// entries. Null for every other kind. Drives the
+ /// 's TextColored color choice.
+ ///
+ public Combat.CombatLineKind? CombatKind { get; init; }
}
diff --git a/src/AcDream.Core/Chat/CombatChatTranslator.cs b/src/AcDream.Core/Chat/CombatChatTranslator.cs
new file mode 100644
index 0000000..48fbf86
--- /dev/null
+++ b/src/AcDream.Core/Chat/CombatChatTranslator.cs
@@ -0,0 +1,248 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+using AcDream.Core.Combat;
+
+namespace AcDream.Core.Chat;
+
+///
+/// Phase I.7: subscribes to 's typed combat
+/// events and emits retail-faithful chat lines into
+/// via .
+///
+///
+/// Templates ported VERBATIM from holtburger
+/// references/holtburger/apps/holtburger-cli/src/pages/game/panels/chat.rs
+/// lines 221-308 (event match) and 561-595 (helper formatters).
+/// Severity buckets map onto holtburger's
+/// info().combat()/warning().combat()/error().combat()
+/// decorators.
+///
+///
+///
+/// Holtburger plumbs health_percent on both AttackerNotification
+/// and DefenderNotification, plus an attack_conditions bitflag.
+/// acdream's currently lacks
+/// health_percent (the wire payload carries it on the defender
+/// side too — see GameEventDefenderNotification) and
+/// attack_conditions; the translator therefore omits those
+/// pieces from the defender line and emits an empty conditions suffix.
+/// When those fields are added to , extend the
+/// templates here without changing the call shape.
+///
+///
+///
+/// Disposable: subscribes on construction, unsubscribes on
+/// . creates one alongside
+/// the live session and disposes it on shutdown.
+///
+///
+public sealed class CombatChatTranslator : IDisposable
+{
+ private readonly CombatState _combat;
+ private readonly ChatLog _chat;
+
+ private readonly Action _onDealt;
+ private readonly Action _onTaken;
+ private readonly Action _onMissed;
+ private readonly Action _onEvaded;
+ private readonly Action _onAttackDone;
+ private readonly Action _onKill;
+
+ private bool _disposed;
+
+ public CombatChatTranslator(CombatState combat, ChatLog chat)
+ {
+ _combat = combat ?? throw new ArgumentNullException(nameof(combat));
+ _chat = chat ?? throw new ArgumentNullException(nameof(chat));
+
+ _onDealt = HandleDamageDealt;
+ _onTaken = HandleDamageTaken;
+ _onMissed = HandleMissedOutgoing;
+ _onEvaded = HandleEvadedIncoming;
+ _onAttackDone = HandleAttackDone;
+ _onKill = HandleKillLanded;
+
+ _combat.DamageDealtAccepted += _onDealt;
+ _combat.DamageTaken += _onTaken;
+ _combat.MissedOutgoing += _onMissed;
+ _combat.EvadedIncoming += _onEvaded;
+ _combat.AttackDone += _onAttackDone;
+ _combat.KillLanded += _onKill;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _combat.DamageDealtAccepted -= _onDealt;
+ _combat.DamageTaken -= _onTaken;
+ _combat.MissedOutgoing -= _onMissed;
+ _combat.EvadedIncoming -= _onEvaded;
+ _combat.AttackDone -= _onAttackDone;
+ _combat.KillLanded -= _onKill;
+ }
+
+ // ── Event handlers ──────────────────────────────────────────────────────
+
+ private void HandleDamageDealt(CombatState.DamageDealt e)
+ {
+ // chat.rs:250-261 — AttackerNotification:
+ // "You hit {} for {} {} damage ({}).{}{}"
+ // (defender, damage, dtype, percent, " Critical hit." | "", conditions_suffix)
+ var line = string.Concat(
+ "You hit ", e.DefenderName,
+ " for ", e.Damage.ToString(CultureInfo.InvariantCulture),
+ " ", FormatDamageType(e.DamageType),
+ " damage (", FormatPercent(e.DamagePercent), ").",
+ // No `Critical` field on DamageDealt today — holtburger plumbs
+ // critical_hit on AttackerNotification too; when CombatState
+ // grows that field, append " Critical hit." here.
+ "",
+ FormatAttackConditionsSuffix(0));
+ _chat.OnCombatLine(line, CombatLineKind.Info);
+ }
+
+ private void HandleDamageTaken(CombatState.DamageIncoming e)
+ {
+ // chat.rs:271-284 — DefenderNotification:
+ // "{} hit you for {} {} damage to your {} ({}).{}{}"
+ // (attacker, damage, dtype, location, percent, critical, conditions)
+ // acdream wire: HitQuadrant carries the DamageLocation enum value
+ // (see ACE GameEventDefenderNotification). DamageIncoming lacks
+ // a health_percent field today, so the "(percent)" piece is
+ // omitted; the rest of the line is template-faithful.
+ var sb = new StringBuilder();
+ sb.Append(e.AttackerName);
+ sb.Append(" hit you for ");
+ sb.Append(e.Damage.ToString(CultureInfo.InvariantCulture));
+ sb.Append(' ');
+ sb.Append(FormatDamageType(e.DamageType));
+ sb.Append(" damage to your ");
+ sb.Append(FormatDamageLocation(e.HitQuadrant));
+ sb.Append('.');
+ if (e.Critical) sb.Append(" Critical hit.");
+ sb.Append(FormatAttackConditionsSuffix(0));
+ _chat.OnCombatLine(sb.ToString(), CombatLineKind.Warning);
+ }
+
+ private void HandleMissedOutgoing(string defenderName)
+ {
+ // chat.rs:286-291 — EvasionAttackerNotification:
+ // "{} evaded your attack."
+ _chat.OnCombatLine($"{defenderName} evaded your attack.", CombatLineKind.Info);
+ }
+
+ private void HandleEvadedIncoming(string attackerName)
+ {
+ // chat.rs:292-297 — EvasionDefenderNotification:
+ // "You evaded {}'s attack."
+ _chat.OnCombatLine($"You evaded {attackerName}'s attack.", CombatLineKind.Info);
+ }
+
+ private void HandleAttackDone(uint attackSequence, uint weenieError)
+ {
+ if (weenieError == 0)
+ {
+ // chat.rs:223-228 — silent on a clean finish (debug tag in
+ // holtburger). We mirror: AttackDone with no error doesn't
+ // need a chat line; the engine state already advanced.
+ return;
+ }
+ // chat.rs:230-234 — "Attack sequence finished with {:?}.".
+ // {:?} formats the WeenieError variant name; without that enum
+ // mapped client-side we surface the hex code (matches the style
+ // of ChatLog.OnWeenieError).
+ var line = "Attack sequence finished with WeenieError 0x"
+ + weenieError.ToString("X4", CultureInfo.InvariantCulture)
+ + ".";
+ _chat.OnCombatLine(line, CombatLineKind.Error);
+ }
+
+ private void HandleKillLanded(string victimName, uint victimGuid)
+ {
+ // chat.rs:301-303 — KillerNotification: "{death_message}".
+ // The server-authoritative death message lives on the
+ // PlayerKilled (0x019E) wire path; CombatState's KillLanded
+ // event surfaces only the victim's display name + guid, so we
+ // synthesize a minimal "You killed Foo." line here. The
+ // detailed sentence (used by retail) arrives separately via
+ // ChatLog.OnPlayerKilled and is rendered as ChatKind.System.
+ _chat.OnCombatLine($"You killed {victimName}.", CombatLineKind.Info);
+ }
+
+ // ── Formatters (ported VERBATIM from chat.rs:561-595) ───────────────────
+
+ ///
+ /// Holtburger format_damage_type at chat.rs:561-568. Joins
+ /// the names of every set bit in the bitflag with '/' and
+ /// lowercases the result. Unknown / zero → "unknown".
+ ///
+ public static string FormatDamageType(uint damageType)
+ {
+ // Bit-name table ports DamageType in
+ // references/holtburger/crates/holtburger-common/src/properties/combat.rs:55-95.
+ // Display names from iter_display_names() then lowercased.
+ var names = new List(4);
+ if ((damageType & 0x1u) != 0) names.Add("slashing");
+ if ((damageType & 0x2u) != 0) names.Add("piercing");
+ if ((damageType & 0x4u) != 0) names.Add("bludgeoning");
+ if ((damageType & 0x8u) != 0) names.Add("cold");
+ if ((damageType & 0x10u) != 0) names.Add("fire");
+ if ((damageType & 0x20u) != 0) names.Add("acid");
+ if ((damageType & 0x40u) != 0) names.Add("electric");
+ if ((damageType & 0x80u) != 0) names.Add("health");
+ if ((damageType & 0x100u) != 0) names.Add("stamina");
+ if ((damageType & 0x200u) != 0) names.Add("mana");
+ if ((damageType & 0x400u) != 0) names.Add("nether");
+ if ((damageType & 0x10000000u) != 0) names.Add("base");
+ return names.Count == 0 ? "unknown" : string.Join("/", names);
+ }
+
+ ///
+ /// Holtburger format_damage_location at chat.rs:574-586. Maps
+ /// the enum (sequential 0..8) to the
+ /// human-readable body part. Anything out of range → "body" so a
+ /// stray HitQuadrant value never crashes the chat line.
+ ///
+ public static string FormatDamageLocation(uint location) => location switch
+ {
+ 0 => "head",
+ 1 => "chest",
+ 2 => "abdomen",
+ 3 => "upper arm",
+ 4 => "lower arm",
+ 5 => "hand",
+ 6 => "upper leg",
+ 7 => "lower leg",
+ 8 => "foot",
+ _ => "body",
+ };
+
+ ///
+ /// Holtburger format_percent at chat.rs:570-572. Emits one
+ /// decimal place using the invariant culture so "54.0%" reads the
+ /// same on every locale.
+ ///
+ public static string FormatPercent(float fraction)
+ => (fraction * 100f).ToString("0.0", CultureInfo.InvariantCulture) + "%";
+
+ ///
+ /// Holtburger format_attack_conditions_suffix at chat.rs:588-595.
+ /// Bracketed comma-joined list; empty when no flags are set. Today
+ /// always empty because acdream's CombatState records do not yet
+ /// plumb the AttackConditions bitflag (Phase I.7 follow-up).
+ ///
+ public static string FormatAttackConditionsSuffix(uint attackConditions)
+ {
+ if (attackConditions == 0) return string.Empty;
+ var names = new List(2);
+ if ((attackConditions & 0x1u) != 0) names.Add("Critical Protection Augmentation");
+ if ((attackConditions & 0x2u) != 0) names.Add("Recklessness");
+ if ((attackConditions & 0x4u) != 0) names.Add("Sneak Attack");
+ if ((attackConditions & 0x8u) != 0) names.Add("Overpower");
+ if (names.Count == 0) return string.Empty;
+ return " [" + string.Join(", ", names) + "]";
+ }
+}
diff --git a/src/AcDream.Core/Combat/CombatLineKind.cs b/src/AcDream.Core/Combat/CombatLineKind.cs
new file mode 100644
index 0000000..8760eda
--- /dev/null
+++ b/src/AcDream.Core/Combat/CombatLineKind.cs
@@ -0,0 +1,27 @@
+namespace AcDream.Core.Combat;
+
+///
+/// Phase I.7: severity bucket for combat-feedback chat lines emitted by
+/// . The values map directly onto
+/// holtburger's info().combat() / warning().combat() /
+/// error().combat() decorators in
+/// references/holtburger/.../panels/chat.rs lines 221-308.
+///
+///
+/// The translator picks the bucket from the kind of event (you-hit-them
+/// is ; they-hit-you is ; an
+/// AttackDone with a non-zero WeenieError is ). The
+/// chat panel maps each bucket to a fixed TextColored rgba.
+///
+///
+public enum CombatLineKind
+{
+ /// Standard outgoing-damage / target-evaded line. Yellow-ish in the panel.
+ Info,
+
+ /// Incoming-damage line. Red-ish in the panel.
+ Warning,
+
+ /// Attack failure (AttackDone with a non-zero WeenieError). Deep red in the panel.
+ Error,
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
index 4e89c4e..16633e2 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
@@ -1,3 +1,7 @@
+using System.Numerics;
+using AcDream.Core.Chat;
+using AcDream.Core.Combat;
+
namespace AcDream.UI.Abstractions.Panels.Chat;
///
@@ -56,7 +60,10 @@ public sealed class ChatPanel : IPanel
return;
}
- var lines = _vm.RecentLines();
+ // Phase I.7: pull the typed-line view so combat entries can
+ // route through TextColored. Non-combat entries still take
+ // the plain Text path (visually identical to the I.4 panel).
+ var lines = _vm.RecentLinesDetailed();
if (lines.Count == 0)
{
renderer.Text("(no messages yet)");
@@ -69,7 +76,15 @@ public sealed class ChatPanel : IPanel
renderer.Separator();
for (int i = 0; i < lines.Count; i++)
{
- renderer.Text(lines[i]);
+ var line = lines[i];
+ if (line.Kind == ChatKind.Combat && line.CombatKind is { } ck)
+ {
+ renderer.TextColored(ColorForCombat(ck), line.Text);
+ }
+ else
+ {
+ renderer.Text(line.Text);
+ }
}
}
@@ -91,4 +106,17 @@ public sealed class ChatPanel : IPanel
renderer.End();
}
+
+ ///
+ /// Phase I.7: per-severity color for combat-feedback chat lines.
+ /// Maps onto holtburger's color_for_tags at chat.rs:330-333
+ /// (info → yellowish, warning → red incoming, error → deep red).
+ ///
+ public static Vector4 ColorForCombat(CombatLineKind kind) => kind switch
+ {
+ CombatLineKind.Info => new Vector4(1.0f, 1.0f, 0.6f, 1.0f),
+ CombatLineKind.Warning => new Vector4(1.0f, 0.5f, 0.5f, 1.0f),
+ CombatLineKind.Error => new Vector4(1.0f, 0.3f, 0.3f, 1.0f),
+ _ => new Vector4(1f, 1f, 1f, 1f),
+ };
}
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
index 6c506fa..14758e4 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
@@ -1,4 +1,5 @@
using AcDream.Core.Chat;
+using AcDream.Core.Combat;
namespace AcDream.UI.Abstractions.Panels.Chat;
@@ -111,6 +112,49 @@ public sealed class ChatVM
// not the formatter).
ChatKind.Emote => $"* {entry.Sender} {entry.Text}",
ChatKind.SoulEmote => $"* {entry.Sender} {entry.Text}",
+ // Phase I.7: combat-line entries are pre-formatted by
+ // CombatChatTranslator using holtburger templates verbatim
+ // (chat.rs:221-308). The translator owns the wording; the VM
+ // just passes through. The panel uses TextColored based on
+ // entry.CombatKind.
+ ChatKind.Combat => entry.Text,
_ => entry.Text,
};
+
+ ///
+ /// Phase I.7: snapshot of the chat tail with kind metadata so
+ /// can pick the right rendering primitive
+ /// per entry (plain Text for most kinds; TextColored
+ /// for combat lines, with the rgba chosen from
+ /// ).
+ ///
+ public IReadOnlyList RecentLinesDetailed()
+ {
+ var snap = _log.Snapshot();
+ int start = Math.Max(0, snap.Length - _displayLimit);
+ int count = snap.Length - start;
+ if (count <= 0) return Array.Empty();
+
+ var lines = new FormattedLine[count];
+ for (int i = 0; i < count; i++)
+ {
+ var entry = snap[start + i];
+ lines[i] = new FormattedLine(
+ Text: FormatEntry(entry),
+ Kind: entry.Kind,
+ CombatKind: entry.CombatKind);
+ }
+ return lines;
+ }
}
+
+///
+/// Phase I.7: formatted chat line with kind metadata. The
+/// switches on +
+/// to pick a rendering primitive
+/// (Text vs TextColored(rgba)).
+///
+public readonly record struct FormattedLine(
+ string Text,
+ ChatKind Kind,
+ CombatLineKind? CombatKind);
diff --git a/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs b/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs
index 85116dd..7647939 100644
--- a/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs
+++ b/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs
@@ -1,4 +1,5 @@
using AcDream.Core.Chat;
+using AcDream.Core.Combat;
using Xunit;
namespace AcDream.Core.Tests.Chat;
@@ -172,4 +173,30 @@ public sealed class ChatLogTests
log.OnLocalSpeech(sender: "Alice", text: "hi", senderGuid: 0xAA, isRanged: false);
Assert.Equal("Alice", log.Snapshot()[0].Sender);
}
+
+ // ── Phase I.7: combat-line adapter ────────────────────────────────────
+
+ [Fact]
+ public void OnCombatLine_DefaultsInfoKind_TagsEntryAsCombat()
+ {
+ var log = new ChatLog();
+ log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).");
+ var e = log.Snapshot()[0];
+ Assert.Equal(ChatKind.Combat, e.Kind);
+ Assert.Equal(CombatLineKind.Info, e.CombatKind);
+ Assert.Equal("You hit Mosswart for 5 slashing damage (50.0%).", e.Text);
+ }
+
+ [Fact]
+ public void OnCombatLine_PreservesExplicitKind()
+ {
+ var log = new ChatLog();
+ log.OnCombatLine("Mosswart hit you for 8 fire damage to your chest.",
+ CombatLineKind.Warning);
+ Assert.Equal(CombatLineKind.Warning, log.Snapshot()[0].CombatKind);
+
+ log.OnCombatLine("Attack sequence finished with WeenieError 0x1234.",
+ CombatLineKind.Error);
+ Assert.Equal(CombatLineKind.Error, log.Snapshot()[1].CombatKind);
+ }
}
diff --git a/tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs b/tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs
new file mode 100644
index 0000000..3d61897
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs
@@ -0,0 +1,175 @@
+using AcDream.Core.Chat;
+using AcDream.Core.Combat;
+using Xunit;
+
+namespace AcDream.Core.Tests.Chat;
+
+///
+/// Phase I.7: subscribes to
+/// 's typed combat events and emits
+/// retail-faithful chat lines into a . Templates
+/// ported VERBATIM from holtburger
+/// references/holtburger/.../panels/chat.rs lines 221-308.
+///
+public sealed class CombatChatTranslatorTests
+{
+ private static (ChatLog, CombatState, CombatChatTranslator) Setup()
+ {
+ var chat = new ChatLog();
+ var combat = new CombatState();
+ var t = new CombatChatTranslator(combat, chat);
+ return (chat, combat, t);
+ }
+
+ [Fact]
+ public void DamageDealt_FormatsHolthurgerAttackerTemplate_AsInfo()
+ {
+ var (chat, combat, _) = Setup();
+ // damage_type 0x01 = SLASH → "slashing"
+ combat.OnAttackerNotification(
+ defenderName: "Mosswart Defiler", damageType: 0x01u,
+ damage: 12u, damagePercent: 0.54f);
+
+ var entry = Assert.Single(chat.Snapshot());
+ Assert.Equal(ChatKind.Combat, entry.Kind);
+ Assert.Equal(CombatLineKind.Info, entry.CombatKind);
+ Assert.Equal("You hit Mosswart Defiler for 12 slashing damage (54.0%).", entry.Text);
+ }
+
+ [Fact]
+ public void DamageTaken_FormatsHolthurgerDefenderTemplate_AsWarning()
+ {
+ var (chat, combat, _) = Setup();
+ // hitQuadrant=1 → "chest"; damageType=0x10 → "fire"; critical=0
+ combat.OnVictimNotification(
+ attackerName: "Mosswart Stalker", attackerGuid: 0xA1u,
+ damageType: 0x10u, damage: 7u,
+ hitQuadrant: 1u, critical: 0u, attackType: 0u);
+
+ var entry = Assert.Single(chat.Snapshot());
+ Assert.Equal(ChatKind.Combat, entry.Kind);
+ Assert.Equal(CombatLineKind.Warning, entry.CombatKind);
+ Assert.Equal("Mosswart Stalker hit you for 7 fire damage to your chest.", entry.Text);
+ }
+
+ [Fact]
+ public void DamageTaken_Critical_AppendsCriticalHitSuffix()
+ {
+ var (chat, combat, _) = Setup();
+ combat.OnVictimNotification(
+ attackerName: "Olthoi Soldier", attackerGuid: 0xB2u,
+ damageType: 0x02u /*PIERCE*/, damage: 99u,
+ hitQuadrant: 0u /*head*/, critical: 1u, attackType: 0u);
+
+ var entry = Assert.Single(chat.Snapshot());
+ Assert.Equal(
+ "Olthoi Soldier hit you for 99 piercing damage to your head. Critical hit.",
+ entry.Text);
+ }
+
+ [Fact]
+ public void MissedOutgoing_FormatsEvasionAttackerTemplate_AsInfo()
+ {
+ var (chat, combat, _) = Setup();
+ combat.OnEvasionAttackerNotification("Mosswart Sniper");
+
+ var entry = Assert.Single(chat.Snapshot());
+ Assert.Equal(ChatKind.Combat, entry.Kind);
+ Assert.Equal(CombatLineKind.Info, entry.CombatKind);
+ Assert.Equal("Mosswart Sniper evaded your attack.", entry.Text);
+ }
+
+ [Fact]
+ public void EvadedIncoming_FormatsEvasionDefenderTemplate_AsInfo()
+ {
+ var (chat, combat, _) = Setup();
+ combat.OnEvasionDefenderNotification("Drudge Slinker");
+
+ var entry = Assert.Single(chat.Snapshot());
+ Assert.Equal(CombatLineKind.Info, entry.CombatKind);
+ Assert.Equal("You evaded Drudge Slinker's attack.", entry.Text);
+ }
+
+ [Fact]
+ public void AttackDone_NonZeroError_EmitsErrorLine()
+ {
+ var (chat, combat, _) = Setup();
+ combat.OnAttackDone(attackSequence: 7, weenieError: 0x1234u);
+
+ var entry = Assert.Single(chat.Snapshot());
+ Assert.Equal(ChatKind.Combat, entry.Kind);
+ Assert.Equal(CombatLineKind.Error, entry.CombatKind);
+ Assert.Contains("Attack sequence finished with", entry.Text);
+ Assert.Contains("0x1234", entry.Text);
+ }
+
+ [Fact]
+ public void AttackDone_ZeroError_EmitsNothing()
+ {
+ // Holtburger silently logs a debug-tag line; we omit since chat
+ // panel doesn't surface debug entries.
+ var (chat, combat, _) = Setup();
+ combat.OnAttackDone(attackSequence: 7, weenieError: 0u);
+
+ Assert.Empty(chat.Snapshot());
+ }
+
+ [Fact]
+ public void KillLanded_EmitsInfoKillerLine()
+ {
+ var (chat, combat, _) = Setup();
+ combat.OnKillerNotification(victimName: "Phyntos Wasp", victimGuid: 0xCAFEu);
+
+ var entry = Assert.Single(chat.Snapshot());
+ Assert.Equal(ChatKind.Combat, entry.Kind);
+ Assert.Equal(CombatLineKind.Info, entry.CombatKind);
+ Assert.Equal("You killed Phyntos Wasp.", entry.Text);
+ }
+
+ [Fact]
+ public void FormatDamageType_PerHolthurgerBitNames_SingleBits()
+ {
+ // Spot-check three representative bits from the holtburger
+ // DamageType enum (combat.rs:55-95).
+ Assert.Equal("slashing", CombatChatTranslator.FormatDamageType(0x01u));
+ Assert.Equal("fire", CombatChatTranslator.FormatDamageType(0x10u));
+ Assert.Equal("nether", CombatChatTranslator.FormatDamageType(0x400u));
+ Assert.Equal("unknown", CombatChatTranslator.FormatDamageType(0u));
+ }
+
+ [Fact]
+ public void FormatDamageType_MultipleBits_JoinsWithSlash()
+ {
+ // Slash + Pierce → "slashing/piercing"
+ Assert.Equal("slashing/piercing",
+ CombatChatTranslator.FormatDamageType(0x01u | 0x02u));
+ }
+
+ [Fact]
+ public void FormatDamageLocation_PerHolthurgerEnum()
+ {
+ Assert.Equal("head", CombatChatTranslator.FormatDamageLocation(0));
+ Assert.Equal("chest", CombatChatTranslator.FormatDamageLocation(1));
+ Assert.Equal("upper arm", CombatChatTranslator.FormatDamageLocation(3));
+ Assert.Equal("foot", CombatChatTranslator.FormatDamageLocation(8));
+ // Out-of-range → graceful "body" fallback (defensive).
+ Assert.Equal("body", CombatChatTranslator.FormatDamageLocation(99));
+ }
+
+ [Fact]
+ public void Dispose_UnsubscribesFromAllEvents()
+ {
+ var (chat, combat, t) = Setup();
+ t.Dispose();
+
+ // Fire every event after disposal — none should produce chat lines.
+ combat.OnAttackerNotification("X", 0x01u, 1u, 1.0f);
+ combat.OnVictimNotification("X", 0u, 0x01u, 1u, 0u, 0u, 0u);
+ combat.OnEvasionAttackerNotification("X");
+ combat.OnEvasionDefenderNotification("X");
+ combat.OnAttackDone(0u, 0xFFu);
+ combat.OnKillerNotification("X", 0u);
+
+ Assert.Empty(chat.Snapshot());
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs
new file mode 100644
index 0000000..fdf9c75
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs
@@ -0,0 +1,92 @@
+using AcDream.Core.Chat;
+using AcDream.Core.Combat;
+using AcDream.UI.Abstractions.Panels.Chat;
+
+namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
+
+///
+/// Phase I.7: surfaces combat-kind entries through
+/// with their original
+/// attached so the panel can pick a
+/// TextColored color per line.
+///
+public sealed class ChatVMCombatTests
+{
+ [Fact]
+ public void FormatEntry_CombatKind_PassesThroughVerbatim()
+ {
+ var entry = new ChatEntry(
+ Kind: ChatKind.Combat,
+ Sender: "",
+ Text: "You hit Mosswart for 5 slashing damage (50.0%).",
+ SenderGuid: 0,
+ ChannelId: 0)
+ { CombatKind = CombatLineKind.Info };
+
+ Assert.Equal(
+ "You hit Mosswart for 5 slashing damage (50.0%).",
+ ChatVM.FormatEntry(entry));
+ }
+
+ [Fact]
+ public void RecentLinesDetailed_CombatEntry_RetainsCombatKind()
+ {
+ var log = new ChatLog();
+ var vm = new ChatVM(log);
+ log.OnCombatLine("Mosswart hit you for 8 fire damage to your chest.",
+ CombatLineKind.Warning);
+
+ var lines = vm.RecentLinesDetailed();
+ var line = Assert.Single(lines);
+ Assert.Equal(ChatKind.Combat, line.Kind);
+ Assert.Equal(CombatLineKind.Warning, line.CombatKind);
+ Assert.Equal("Mosswart hit you for 8 fire damage to your chest.", line.Text);
+ }
+
+ [Fact]
+ public void RecentLinesDetailed_NonCombatEntry_HasNullCombatKind()
+ {
+ var log = new ChatLog();
+ var vm = new ChatVM(log);
+ log.OnLocalSpeech("Alice", "hi", senderGuid: 0xAA, isRanged: false);
+
+ var line = Assert.Single(vm.RecentLinesDetailed());
+ Assert.Equal(ChatKind.LocalSpeech, line.Kind);
+ Assert.Null(line.CombatKind);
+ Assert.Equal("Alice: hi", line.Text);
+ }
+
+ [Fact]
+ public void ChatPanel_RendersCombatLine_ViaTextColored()
+ {
+ var log = new ChatLog();
+ var vm = new ChatVM(log);
+ log.OnLocalSpeech("Alice", "hi", senderGuid: 0xAA, isRanged: false);
+ log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).",
+ CombatLineKind.Info);
+
+ var panel = new ChatPanel(vm);
+ var bus = new RecordingChatBus();
+ var renderer = new FakePanelRenderer { InputTextSubmitNextSubmitted = null };
+
+ panel.Render(new PanelContext(0.016f, bus), renderer);
+
+ // Plain LocalSpeech entry → Text; combat entry → TextColored.
+ Assert.Contains(renderer.Calls, c =>
+ c.Method == "Text" && (string?)c.Args[0] == "Alice: hi");
+ var coloredCall = Assert.Single(
+ renderer.Calls,
+ c => c.Method == "TextColored");
+ Assert.Equal(
+ "You hit Mosswart for 5 slashing damage (50.0%).",
+ (string?)coloredCall.Args[1]);
+ Assert.Equal(
+ ChatPanel.ColorForCombat(CombatLineKind.Info),
+ (System.Numerics.Vector4)coloredCall.Args[0]!);
+ }
+
+ private sealed class RecordingChatBus : ICommandBus
+ {
+ public void Publish(T command) where T : notnull { /* no-op */ }
+ }
+}