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) + "]"; } }