From 3d26c8efdec0b7d3ec8b321e2523132833de1e39 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 19:55:15 +0200 Subject: [PATCH] feat(chat): #20 CombatChatTranslator - retail-faithful combat -> ChatLog templates Subscribes to CombatState's DamageDealtAccepted / DamageTaken / MissedOutgoing / EvadedIncoming / AttackDone / KillLanded events and emits chat-line text into ChatLog.OnCombatLine, mirroring holtburger's templates verbatim from references/holtburger/apps/ holtburger-cli/src/pages/game/panels/chat.rs:221-308. Pieces: - ChatLog: new ChatKind.Combat value; new CombatLineKind enum (Info / Warning / Error) on ChatEntry; OnCombatLine(text, kind) adapter. - CombatChatTranslator (Core, IDisposable). Static formatters: FormatDamageType (slashing/piercing/bludgeoning/fire/cold/acid/ electric/nether), FormatDamageLocation (head/chest/abdomen/ upper arm/lower arm/hand/upper leg/lower leg/foot), FormatPercent, FormatAttackConditionsSuffix. - ChatVM.RecentLinesDetailed() returns FormattedLine records with kind metadata so panels can render combat lines colored. - ChatPanel switches on Kind/CombatKind: combat-Info -> yellow, combat-Warning -> red incoming-damage, combat-Error -> deep red, all others -> existing renderer.Text path. - GameWindow constructs translator after GameEventWiring.WireAll; disposes in OnClosing + live-session failure path. Templates landed: Attacker: "You hit {def} for {dmg} {dtype} damage ({hp%}). [Crit]{suffix}" Defender: "{atk} hit you for {dmg} {dtype} damage to your {loc} ({hp%})..." Evade-out: "{def} evaded your attack." Evade-in: "You evaded {atk}'s attack." AttackErr: "Attack sequence finished with {error}." Kill: synthesized "You killed {name}." + server PlayerKilled death-message arrives separately via ChatLog.OnPlayerKilled. Deviations from holtburger templates (documented in source): - DamageDealt omits Critical-hit suffix until CombatState.DamageDealt carries the flag (defender-side has it; attacker-side doesn't yet). - DamageTaken omits (health%) until CombatState.DamageIncoming parses the wire health-percent field. - AttackConditions suffix is implemented but always empty until the bitflag is plumbed into CombatState records. 18 new tests (12 translator + 4 ChatVMCombat + 2 ChatLog). Solution total: 978 green (243 Core.Net + 639 Core + 96 UI). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 17 ++ src/AcDream.Core/Chat/ChatLog.cs | 39 +++ src/AcDream.Core/Chat/CombatChatTranslator.cs | 248 ++++++++++++++++++ src/AcDream.Core/Combat/CombatLineKind.cs | 27 ++ .../Panels/Chat/ChatPanel.cs | 32 ++- .../Panels/Chat/ChatVM.cs | 44 ++++ tests/AcDream.Core.Tests/Chat/ChatLogTests.cs | 27 ++ .../Chat/CombatChatTranslatorTests.cs | 175 ++++++++++++ .../Panels/Chat/ChatVMCombatTests.cs | 92 +++++++ 9 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 src/AcDream.Core/Chat/CombatChatTranslator.cs create mode 100644 src/AcDream.Core/Combat/CombatLineKind.cs create mode 100644 tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs 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 */ } + } +}