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>
248 lines
10 KiB
C#
248 lines
10 KiB
C#
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) + "]";
|
|
}
|
|
}
|