acdream/src/AcDream.Core/Chat/CombatChatTranslator.cs
Erik 34d8a3c0e7 fix(chat): CH1 review fixes — sbb-idiom channel catch-all, command-output typing
Applies the Opus review findings on CH1 (172c6f9a), the exact retail chat
color table. Two blockers plus should-fixes/nits, one commit:

BLOCKER 1 — LegacyChannelChatType.Resolve's channel-bit table was wrong.
Binary Ninja renders retail's `neg esi; sbb esi, esi` idiom (a branchless
select between Channel 0x08 and Channel_Send 0x09) as the trivial pseudo-C
`esi - esi` (always 0), hiding the real values. Corrected by decoding the
raw bytes at the PDB-paired binary: HEAR sbb site VA 0x00570F0A (mask -6 ->
0x08), SEND sbb site VA 0x00570D4F (mask -5 -> 0x09). The generic
admin/audit/sentinel catch-all is Channel/Channel_Send, NOT Abuse (0x0E) —
Abuse is retail's ONLY 0x0E producer (bit 0x0001). The unnamed
FellowBroadcast bit (0x4000000) is hear=Channel(0x08)/send=Fellowship(0x13),
not a flat 0x13. ACE's PDB-sourced Channel enum corroborates. Introduces
`RetailLogTextType`, the 34-value named enum for the wire LogTextType space
(values only, no color — Core stays presentation-free).

BLOCKER 2 — three ChatLog.OnSystemMessage sinks (ChatVM.ShowSystemMessage,
LiveSessionRuntimeFactory's ShowSystemMessage delegate,
HeadlessGameplayOperations.DisplayMessage) were typing ALL
ClientCommandController output 0x1A (bright red), including informational
command output (@version, /loc, friends list, usage lines). Retail types
the great majority of that output 0x00 Default (green) and reserves 0x1A
for genuine refusals/errors. Reverted to 0x00 with a comment noting the
refusal-vs-info split lands with CH2's SpewBox producer rewiring. The five
App composition sites that pass 0x1A for actual refusal text
(InteractionRetainedUiComposition, SessionPlayerComposition) were already
correct and are untouched (aside from converting the literal to the new
enum).

Also: AP-176 divergence-register row for OnWeenieError/OnCombatLine's
single-type approximation of retail's per-code/per-message dispatch; a
carry-forward test for the out-of-range LogTextType color fallback in
ChatWindowController; decomp-confirmed anchors replacing ACE-inferred
citations in CombatChatTranslator and ChatLog.OnPlayerKilled; required
(non-optional) logTextType parameters on OnLocalSpeech/OnTellReceived/
OnCombatLine/OnSelfSent since no production caller relied on a default;
LegacyChannelChatType.Resolve's parameter renamed channelBit -> channelId
with a doc note on multi-bit ids; corrections to the color-table research
doc's §3.3 wire tables; and issue #359 for the pre-existing (not
CH1-introduced) 0x019E PlayerKilled participant-suppression gap retail has
and acdream lacks.

dotnet build clean; full Release suite 11,835 passed / 4 skipped / 0 failed
(11,839 total), up from the CH1 baseline of 11,833/4/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 16:03:13 +02:00

273 lines
12 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<string, uint> _onKill;
private bool _disposed;
public CombatChatTranslator(
CombatState combat,
ChatLog chat,
Func<bool>? accepting = null)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_onDealt = value =>
{
if (accepting?.Invoke() != false) HandleDamageDealt(value);
};
_onTaken = value =>
{
if (accepting?.Invoke() != false) HandleDamageTaken(value);
};
_onMissed = value =>
{
if (accepting?.Invoke() != false) HandleMissedOutgoing(value);
};
_onEvaded = value =>
{
if (accepting?.Invoke() != false) HandleEvadedIncoming(value);
};
_onKill = (name, guid) =>
{
if (accepting?.Invoke() != false) HandleKillLanded(name, guid);
};
_combat.DamageDealtAccepted += _onDealt;
_combat.DamageTaken += _onTaken;
_combat.MissedOutgoing += _onMissed;
_combat.EvadedIncoming += _onEvaded;
_combat.KillLanded += _onKill;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_combat.DamageDealtAccepted -= _onDealt;
_combat.DamageTaken -= _onTaken;
_combat.MissedOutgoing -= _onMissed;
_combat.EvadedIncoming -= _onEvaded;
_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));
// Combat_Self (0x16): decomp-CONFIRMED, not ACE-inferred —
// ClientCombatSystem::HandleAttackerNotificationEvent @0x0056B420
// sets type 0x16 at @0x0056B761, the binary's only 0x16 producer
// for this notification family. ACE's ChatMessageType.CombatSelf
// comment (Player_Combat.cs:162-163, GameEventAttackerNotification,
// "You hit X...") corroborates.
_chat.OnCombatLine(line, logTextType: (uint)RetailLogTextType.CombatSelf, kind: 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));
// Combat_Enemy (0x15): decomp-CONFIRMED, not ACE-inferred —
// ClientCombatSystem::HandleDefenderNotificationEvent @0x0056C920
// sets type 0x15 at @0x0056D4B4, the binary's only 0x15 producer
// for this notification family. ACE's ChatMessageType.CombatEnemy
// comment (Player_Combat.cs:541, GameEventDefenderNotification,
// "X hit you...") corroborates.
_chat.OnCombatLine(sb.ToString(), logTextType: (uint)RetailLogTextType.CombatEnemy, kind: CombatLineKind.Warning);
}
private void HandleMissedOutgoing(string defenderName)
{
// chat.rs:286-291 — EvasionAttackerNotification:
// "{} evaded your attack."
// Combat_Self (0x16): decomp-CONFIRMED, not ACE-inferred —
// ClientCombatSystem::HandleEvasionAttackerNotificationEvent
// @0x0056C7A0 sets type 0x16 at @0x0056C870, the binary's only
// 0x16 producer for this notification family (same slot as the
// hit-dealt line above). ACE's ChatMessageType.CombatSelf comment
// (Player_Combat.cs:150, GameEventEvasionAttackerNotification)
// corroborates.
_chat.OnCombatLine($"{defenderName} evaded your attack.", logTextType: (uint)RetailLogTextType.CombatSelf, kind: CombatLineKind.Info);
}
private void HandleEvadedIncoming(string attackerName)
{
// chat.rs:292-297 — EvasionDefenderNotification:
// "You evaded {}'s attack."
// Combat_Enemy (0x15): decomp-CONFIRMED, not ACE-inferred —
// ClientCombatSystem::HandleEvasionDefenderNotificationEvent
// @0x0056C620 sets type 0x15 at @0x0056C710, the binary's only
// 0x15 producer for this notification family. ACE's
// ChatMessageType.CombatEnemy comment (Player_Combat.cs:345,
// GameEventEvasionDefenderNotification) corroborates.
_chat.OnCombatLine($"You evaded {attackerName}'s attack.", logTextType: (uint)RetailLogTextType.CombatEnemy, kind: CombatLineKind.Info);
}
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.
// LogTextType 0x00 Default: retail's own kill/death notification
// handler (VictimNotification 0x01AC + KillerNotification 0x01AD,
// both via ClientCombatSystem::HandleKillerNotificationEvent
// @0x0056C410) calls AddTextToScroll(..., 0, 1, 0) — see
// ChatLog.OnPlayerKilled's identical citation.
_chat.OnCombatLine($"You killed {victimName}.", logTextType: 0x00u, kind: 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) + "]";
}
}