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) <noreply@anthropic.com>
This commit is contained in:
parent
ca968fc766
commit
3d26c8efde
9 changed files with 699 additions and 2 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -166,6 +166,30 @@ public sealed class ChatLog
|
|||
ChannelId: 0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.7: combat-translator emits a pre-formatted line. The
|
||||
/// translator (<see cref="CombatChatTranslator"/>) subscribes to
|
||||
/// <see cref="Combat.CombatState"/> 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
|
||||
/// <see cref="Combat.CombatLineKind"/> so the panel can color the
|
||||
/// line. Maps to holtburger's <c>info().combat()</c> /
|
||||
/// <c>warning().combat()</c> / <c>error().combat()</c> tag flow at
|
||||
/// <c>chat.rs:221-308</c>.
|
||||
/// </summary>
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Echo the player's own outbound message after local send.</summary>
|
||||
public void OnSelfSent(ChatKind kind, string text, string targetOrChannel = "")
|
||||
{
|
||||
|
|
@ -201,6 +225,14 @@ public enum ChatKind
|
|||
Popup,
|
||||
Emote,
|
||||
SoulEmote,
|
||||
/// <summary>
|
||||
/// Phase I.7: a combat feedback line emitted by
|
||||
/// <see cref="CombatChatTranslator"/> from <see cref="Combat.CombatState"/>
|
||||
/// events. The accompanying <see cref="ChatEntry.CombatKind"/> field
|
||||
/// drives panel coloring (info / warning / error per holtburger
|
||||
/// <c>chat.rs:221-308</c>).
|
||||
/// </summary>
|
||||
Combat,
|
||||
}
|
||||
|
||||
public readonly record struct ChatEntry(
|
||||
|
|
@ -211,4 +243,11 @@ public readonly record struct ChatEntry(
|
|||
uint ChannelId)
|
||||
{
|
||||
public DateTime Received { get; init; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.7: severity bucket for <see cref="ChatKind.Combat"/>
|
||||
/// entries. Null for every other kind. Drives the
|
||||
/// <see cref="ChatPanel"/>'s <c>TextColored</c> color choice.
|
||||
/// </summary>
|
||||
public Combat.CombatLineKind? CombatKind { get; init; }
|
||||
}
|
||||
|
|
|
|||
248
src/AcDream.Core/Chat/CombatChatTranslator.cs
Normal file
248
src/AcDream.Core/Chat/CombatChatTranslator.cs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AcDream.Core.Combat;
|
||||
|
||||
namespace AcDream.Core.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.7: subscribes to <see cref="CombatState"/>'s typed combat
|
||||
/// events and emits retail-faithful chat lines into <see cref="ChatLog"/>
|
||||
/// via <see cref="ChatLog.OnCombatLine"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Templates ported VERBATIM from holtburger
|
||||
/// <c>references/holtburger/apps/holtburger-cli/src/pages/game/panels/chat.rs</c>
|
||||
/// lines 221-308 (event match) and 561-595 (helper formatters).
|
||||
/// Severity buckets map onto holtburger's
|
||||
/// <c>info().combat()</c>/<c>warning().combat()</c>/<c>error().combat()</c>
|
||||
/// decorators.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Holtburger plumbs <c>health_percent</c> on both AttackerNotification
|
||||
/// and DefenderNotification, plus an <c>attack_conditions</c> bitflag.
|
||||
/// acdream's <see cref="CombatState.DamageIncoming"/> currently lacks
|
||||
/// <c>health_percent</c> (the wire payload carries it on the defender
|
||||
/// side too — see <c>GameEventDefenderNotification</c>) and
|
||||
/// <c>attack_conditions</c>; the translator therefore omits those
|
||||
/// pieces from the defender line and emits an empty conditions suffix.
|
||||
/// When those fields are added to <see cref="CombatState"/>, extend the
|
||||
/// templates here without changing the call shape.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Disposable: subscribes on construction, unsubscribes on
|
||||
/// <see cref="Dispose"/>. <see cref="GameWindow"/> creates one alongside
|
||||
/// the live session and disposes it on shutdown.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CombatChatTranslator : IDisposable
|
||||
{
|
||||
private readonly CombatState _combat;
|
||||
private readonly ChatLog _chat;
|
||||
|
||||
private readonly Action<CombatState.DamageDealt> _onDealt;
|
||||
private readonly Action<CombatState.DamageIncoming> _onTaken;
|
||||
private readonly Action<string> _onMissed;
|
||||
private readonly Action<string> _onEvaded;
|
||||
private readonly Action<uint, uint> _onAttackDone;
|
||||
private readonly Action<string, uint> _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) ───────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Holtburger <c>format_damage_type</c> at chat.rs:561-568. Joins
|
||||
/// the names of every set bit in the bitflag with <c>'/'</c> and
|
||||
/// lowercases the result. Unknown / zero → <c>"unknown"</c>.
|
||||
/// </summary>
|
||||
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<string>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holtburger <c>format_damage_location</c> at chat.rs:574-586. Maps
|
||||
/// the <see cref="DamageLocation"/> 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.
|
||||
/// </summary>
|
||||
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",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Holtburger <c>format_percent</c> at chat.rs:570-572. Emits one
|
||||
/// decimal place using the invariant culture so "54.0%" reads the
|
||||
/// same on every locale.
|
||||
/// </summary>
|
||||
public static string FormatPercent(float fraction)
|
||||
=> (fraction * 100f).ToString("0.0", CultureInfo.InvariantCulture) + "%";
|
||||
|
||||
/// <summary>
|
||||
/// Holtburger <c>format_attack_conditions_suffix</c> 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).
|
||||
/// </summary>
|
||||
public static string FormatAttackConditionsSuffix(uint attackConditions)
|
||||
{
|
||||
if (attackConditions == 0) return string.Empty;
|
||||
var names = new List<string>(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) + "]";
|
||||
}
|
||||
}
|
||||
27
src/AcDream.Core/Combat/CombatLineKind.cs
Normal file
27
src/AcDream.Core/Combat/CombatLineKind.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
namespace AcDream.Core.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.7: severity bucket for combat-feedback chat lines emitted by
|
||||
/// <see cref="CombatChatTranslator"/>. The values map directly onto
|
||||
/// holtburger's <c>info().combat()</c> / <c>warning().combat()</c> /
|
||||
/// <c>error().combat()</c> decorators in
|
||||
/// <c>references/holtburger/.../panels/chat.rs</c> lines 221-308.
|
||||
///
|
||||
/// <para>
|
||||
/// The translator picks the bucket from the kind of event (you-hit-them
|
||||
/// is <see cref="Info"/>; they-hit-you is <see cref="Warning"/>; an
|
||||
/// AttackDone with a non-zero WeenieError is <see cref="Error"/>). The
|
||||
/// chat panel maps each bucket to a fixed <c>TextColored</c> rgba.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum CombatLineKind
|
||||
{
|
||||
/// <summary>Standard outgoing-damage / target-evaded line. Yellow-ish in the panel.</summary>
|
||||
Info,
|
||||
|
||||
/// <summary>Incoming-damage line. Red-ish in the panel.</summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>Attack failure (AttackDone with a non-zero WeenieError). Deep red in the panel.</summary>
|
||||
Error,
|
||||
}
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.7: per-severity color for combat-feedback chat lines.
|
||||
/// Maps onto holtburger's <c>color_for_tags</c> at chat.rs:330-333
|
||||
/// (info → yellowish, warning → red incoming, error → deep red).
|
||||
/// </summary>
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.7: snapshot of the chat tail with kind metadata so
|
||||
/// <see cref="ChatPanel"/> can pick the right rendering primitive
|
||||
/// per entry (plain <c>Text</c> for most kinds; <c>TextColored</c>
|
||||
/// for combat lines, with the rgba chosen from
|
||||
/// <see cref="ChatEntry.CombatKind"/>).
|
||||
/// </summary>
|
||||
public IReadOnlyList<FormattedLine> RecentLinesDetailed()
|
||||
{
|
||||
var snap = _log.Snapshot();
|
||||
int start = Math.Max(0, snap.Length - _displayLimit);
|
||||
int count = snap.Length - start;
|
||||
if (count <= 0) return Array.Empty<FormattedLine>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.7: formatted chat line with kind metadata. The
|
||||
/// <see cref="ChatPanel"/> switches on <see cref="Kind"/> +
|
||||
/// <see cref="CombatKind"/> to pick a rendering primitive
|
||||
/// (<c>Text</c> vs <c>TextColored(rgba)</c>).
|
||||
/// </summary>
|
||||
public readonly record struct FormattedLine(
|
||||
string Text,
|
||||
ChatKind Kind,
|
||||
CombatLineKind? CombatKind);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
175
tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs
Normal file
175
tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.7: <see cref="CombatChatTranslator"/> subscribes to
|
||||
/// <see cref="CombatState"/>'s typed combat events and emits
|
||||
/// retail-faithful chat lines into a <see cref="ChatLog"/>. Templates
|
||||
/// ported VERBATIM from holtburger
|
||||
/// <c>references/holtburger/.../panels/chat.rs</c> lines 221-308.
|
||||
/// </summary>
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.7: <see cref="ChatVM"/> surfaces combat-kind entries through
|
||||
/// <see cref="ChatVM.RecentLinesDetailed"/> with their original
|
||||
/// <see cref="CombatLineKind"/> attached so the panel can pick a
|
||||
/// <c>TextColored</c> color per line.
|
||||
/// </summary>
|
||||
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>(T command) where T : notnull { /* no-op */ }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue