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
|
|
@ -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