diff --git a/MosswartMassacre/ChatEventRouter.cs b/MosswartMassacre/ChatEventRouter.cs index 9729ffa..65a5adf 100644 --- a/MosswartMassacre/ChatEventRouter.cs +++ b/MosswartMassacre/ChatEventRouter.cs @@ -14,10 +14,12 @@ namespace MosswartMassacre private readonly IPluginLogger _logger; private readonly KillTracker _killTracker; private RareTracker _rareTracker; + private CombatStatsTracker _combatTracker; private readonly Action _onRareCountChanged; private readonly Action _onAllegianceReport; internal void SetRareTracker(RareTracker rareTracker) => _rareTracker = rareTracker; + internal void SetCombatTracker(CombatStatsTracker combatTracker) => _combatTracker = combatTracker; internal ChatEventRouter( IPluginLogger logger, @@ -38,6 +40,7 @@ namespace MosswartMassacre try { _killTracker.CheckForKill(e.Text); + _combatTracker?.ProcessChatLine(e.Text); if (_rareTracker != null && _rareTracker.CheckForRare(e.Text, out string rareText)) { diff --git a/MosswartMassacre/CombatInfo.cs b/MosswartMassacre/CombatInfo.cs new file mode 100644 index 0000000..6457ca6 --- /dev/null +++ b/MosswartMassacre/CombatInfo.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace MosswartMassacre +{ + [JsonConverter(typeof(StringEnumConverter))] + public enum DamageElement + { + None, + Unknown, + Typeless, + Slash, + Pierce, + Bludgeon, + Fire, + Cold, + Acid, + Electric, + } + + [JsonConverter(typeof(StringEnumConverter))] + public enum AttackType + { + Unknown, + MeleeMissile, + Magic, + } + + /// + /// Per-element damage statistics within a single attack type. + /// Mirrors Mag-Tools CombatInfo.DamageByAttackType.DamageByElement. + /// + public class DamageStats + { + [JsonProperty("total_attacks")] public int TotalAttacks; + [JsonProperty("failed_attacks")] public int FailedAttacks; + [JsonProperty("crits")] public int Crits; + [JsonProperty("total_normal_damage")] public int TotalNormalDamage; + [JsonProperty("max_normal_damage")] public int MaxNormalDamage; + [JsonProperty("total_crit_damage")] public int TotalCritDamage; + [JsonProperty("max_crit_damage")] public int MaxCritDamage; + [JsonProperty("killing_blows")] public int KillingBlows; + + [JsonProperty("damage")] + public int Damage => TotalNormalDamage + TotalCritDamage; + } + + /// + /// Per-monster combat record. Stores offense and defense breakdowns + /// keyed by AttackType → DamageElement → DamageStats. Mirrors + /// Mag-Tools CombatInfo but without the source/target pair split + /// (we always track relative to the local character). + /// + public class MonsterCombatRecord + { + [JsonProperty("name")] public string Name; + [JsonProperty("kill_count")] public int KillCount; + [JsonProperty("damage_given")] public long DamageGiven; + [JsonProperty("damage_received")] public long DamageReceived; + [JsonProperty("aetheria_surges")] public int AetheriaSurges; + [JsonProperty("cloak_surges")] public int CloakSurges; + + // offense[AttackType][DamageElement] = stats for damage WE dealt + [JsonProperty("offense")] + public Dictionary> Offense + = new Dictionary>(); + + // defense[AttackType][DamageElement] = stats for damage WE received + [JsonProperty("defense")] + public Dictionary> Defense + = new Dictionary>(); + + public DamageStats GetOrCreateStats( + Dictionary> side, + AttackType at, DamageElement el) + { + if (!side.TryGetValue(at, out var byElement)) + { + byElement = new Dictionary(); + side[at] = byElement; + } + if (!byElement.TryGetValue(el, out var stats)) + { + stats = new DamageStats(); + byElement[el] = stats; + } + return stats; + } + + // ── Aggregate helpers matching Mag-Tools CombatTrackerGUI calculations ── + + public int TotalOffenseAttacks => + Offense.Values.SelectMany(d => d.Values).Sum(s => s.TotalAttacks); + public int TotalOffenseFailedAttacks => + Offense.Values.SelectMany(d => d.Values).Sum(s => s.FailedAttacks); + public int TotalOffenseCrits => + Offense.Values.SelectMany(d => d.Values).Sum(s => s.Crits); + public long TotalOffenseNormalDamage => + Offense.Values.SelectMany(d => d.Values).Sum(s => (long)s.TotalNormalDamage); + public int MaxOffenseNormalDamage => + Offense.Values.SelectMany(d => d.Values).Select(s => s.MaxNormalDamage).DefaultIfEmpty(0).Max(); + public long TotalOffenseCritDamage => + Offense.Values.SelectMany(d => d.Values).Sum(s => (long)s.TotalCritDamage); + public int MaxOffenseCritDamage => + Offense.Values.SelectMany(d => d.Values).Select(s => s.MaxCritDamage).DefaultIfEmpty(0).Max(); + + public int TotalDefenseAttacks => + Defense.Values.SelectMany(d => d.Values).Sum(s => s.TotalAttacks); + public int TotalDefenseFailedAttacks => + Defense.Values.SelectMany(d => d.Values).Sum(s => s.FailedAttacks); + + // Defense damage by element for the breakdown grid + public int GetDefenseDamage(AttackType at, DamageElement el) + { + if (!Defense.TryGetValue(at, out var byEl)) return 0; + if (!byEl.TryGetValue(el, out var stats)) return 0; + return stats.Damage; + } + } + + /// + /// Holds all combat data for a single session (or lifetime accumulation). + /// Serializes cleanly to JSON for the WebSocket combat_stats envelope. + /// + public class CombatSessionState + { + [JsonProperty("total_damage_given")] public long TotalDamageGiven; + [JsonProperty("total_damage_received")] public long TotalDamageReceived; + [JsonProperty("total_kills")] public int TotalKills; + [JsonProperty("total_aetheria_surges")] public int TotalAetheriaSurges; + [JsonProperty("total_cloak_surges")] public int TotalCloakSurges; + [JsonProperty("session_start")] public string SessionStart; + + [JsonProperty("monsters")] + public Dictionary Monsters + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public MonsterCombatRecord GetOrCreateMonster(string name) + { + if (!Monsters.TryGetValue(name, out var rec)) + { + rec = new MonsterCombatRecord { Name = name }; + Monsters[name] = rec; + } + return rec; + } + + public void Clear() + { + TotalDamageGiven = 0; + TotalDamageReceived = 0; + TotalKills = 0; + TotalAetheriaSurges = 0; + TotalCloakSurges = 0; + SessionStart = DateTime.UtcNow.ToString("o"); + Monsters.Clear(); + } + } +} diff --git a/MosswartMassacre/CombatStatsTracker.cs b/MosswartMassacre/CombatStatsTracker.cs new file mode 100644 index 0000000..18d3609 --- /dev/null +++ b/MosswartMassacre/CombatStatsTracker.cs @@ -0,0 +1,524 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Decal.Adapter; +using Newtonsoft.Json; + +namespace MosswartMassacre +{ + /// + /// Parses Asheron's Call combat chat messages into structured stats, + /// matching every pattern tracked by Mag-Tools' Combat Tracker + /// (StandardTracker + AetheriaTracker + CloakTracker). + /// + /// Maintains two CombatSessionState objects: + /// - _sessionState: cleared on login / manual reset + /// - _lifetimeState: accumulated across sessions, never cleared + /// + /// Every 10 seconds, sends a combat_stats snapshot to MosswartOverlord + /// via WebSocket so the browser dashboard can display the same grid + /// that Mag-Tools shows in-game. + /// + public class CombatStatsTracker : IDisposable + { + private const int SendIntervalMs = 10000; + + private readonly IPluginLogger _logger; + private CombatSessionState _sessionState = new CombatSessionState(); + private CombatSessionState _lifetimeState = new CombatSessionState(); + private System.Windows.Forms.Timer _sendTimer; + private bool _dirty; + private bool _disposed; + + public CombatStatsTracker(IPluginLogger logger) + { + _logger = logger; + _sessionState.SessionStart = DateTime.UtcNow.ToString("o"); + _lifetimeState.SessionStart = DateTime.UtcNow.ToString("o"); + } + + public void Start() + { + if (_sendTimer != null) return; + _sendTimer = new System.Windows.Forms.Timer(); + _sendTimer.Interval = SendIntervalMs; + _sendTimer.Tick += OnSendTick; + _sendTimer.Start(); + } + + public void Stop() + { + if (_sendTimer != null) + { + _sendTimer.Stop(); + _sendTimer.Tick -= OnSendTick; + _sendTimer.Dispose(); + _sendTimer = null; + } + } + + public void RestartSession() + { + _sessionState.Clear(); + _dirty = true; + } + + // ═══════════════════════════════════════════════════════════════ + // Public entry point — called from ChatEventRouter.OnChatText + // ═══════════════════════════════════════════════════════════════ + + public void ProcessChatLine(string text) + { + if (string.IsNullOrEmpty(text)) return; + + try + { + // Skip non-combat chat (tells, channels, etc.) + if (IsNonCombatChat(text)) return; + + // Detect prefix flags (Mag-Tools does this via text.Contains) + bool isCrit = text.Contains("Critical hit!"); + bool isOverpower = text.Contains("Overpower!"); + bool isSneakAttack = text.Contains("Sneak Attack!"); + bool isRecklessness = text.Contains("Recklessness!"); + + string myName = SafeCharacterName(); + + // ── Aetheria surges ── + if (TryParseAetheriaSurge(text, myName)) + return; + + // ── Cloak surges ── + if (TryParseCloakSurge(text, myName)) + return; + + // ── Evade / Resist (failed attacks) ── + if (TryParseFailedAttack(text, myName)) + return; + + // ── Kill messages ── + if (TryParseKill(text, myName)) + return; + + // ── Melee/Missile received ── + if (TryMatchPatterns(_meleeMissileReceived, text, out string recvSource, out int recvDmg)) + { + RecordHit(recvSource, myName, AttackType.MeleeMissile, GetElementFromText(text), + recvDmg, isCrit, isOverpower, isSneakAttack, isRecklessness); + return; + } + + // ── Melee/Missile given ── + if (TryMatchPatterns(_meleeMissileGiven, text, out string givenTarget, out int givenDmg)) + { + RecordHit(myName, givenTarget, AttackType.MeleeMissile, GetElementFromText(text), + givenDmg, isCrit, isOverpower, isSneakAttack, isRecklessness); + return; + } + + // ── Magic received ── + if (TryMatchPatterns(_magicReceived, text, out string magRecvSource, out int magRecvDmg)) + { + RecordHit(magRecvSource, myName, AttackType.Magic, GetElementFromText(text), + magRecvDmg, isCrit, isOverpower, isSneakAttack, isRecklessness); + return; + } + + // ── Magic given ── + if (TryMatchPatterns(_magicGiven, text, out string magGivenTarget, out int magGivenDmg)) + { + RecordHit(myName, magGivenTarget, AttackType.Magic, GetElementFromText(text), + magGivenDmg, isCrit, isOverpower, isSneakAttack, isRecklessness); + return; + } + } + catch (Exception ex) + { + _logger?.Log($"[CombatStats] Parse error: {ex.Message}"); + } + } + + // ═══════════════════════════════════════════════════════════════ + // Recording helpers + // ═══════════════════════════════════════════════════════════════ + + private void RecordHit(string source, string target, AttackType at, DamageElement el, + int damage, bool isCrit, bool isOverpower, bool isSneakAttack, bool isRecklessness) + { + string myName = SafeCharacterName(); + bool weAreSource = source.Equals(myName, StringComparison.OrdinalIgnoreCase); + string monsterName = weAreSource ? target : source; + if (string.IsNullOrEmpty(monsterName)) return; + + Action apply = state => + { + var rec = state.GetOrCreateMonster(monsterName); + var side = weAreSource ? rec.Offense : rec.Defense; + var stats = rec.GetOrCreateStats(side, at, el); + + stats.TotalAttacks++; + if (isCrit) + { + stats.Crits++; + stats.TotalCritDamage += damage; + if (damage > stats.MaxCritDamage) stats.MaxCritDamage = damage; + } + else + { + stats.TotalNormalDamage += damage; + if (damage > stats.MaxNormalDamage) stats.MaxNormalDamage = damage; + } + + if (weAreSource) + { + rec.DamageGiven += damage; + state.TotalDamageGiven += damage; + } + else + { + rec.DamageReceived += damage; + state.TotalDamageReceived += damage; + } + }; + + apply(_sessionState); + apply(_lifetimeState); + _dirty = true; + } + + private void RecordFailedAttack(string source, string target, AttackType at) + { + string myName = SafeCharacterName(); + bool weAreSource = source.Equals(myName, StringComparison.OrdinalIgnoreCase); + string monsterName = weAreSource ? target : source; + if (string.IsNullOrEmpty(monsterName)) return; + + Action apply = state => + { + var rec = state.GetOrCreateMonster(monsterName); + var side = weAreSource ? rec.Offense : rec.Defense; + var stats = rec.GetOrCreateStats(side, at, DamageElement.None); + stats.TotalAttacks++; + stats.FailedAttacks++; + }; + + apply(_sessionState); + apply(_lifetimeState); + _dirty = true; + } + + private void RecordKill(string targetName) + { + if (string.IsNullOrEmpty(targetName)) return; + + Action apply = state => + { + var rec = state.GetOrCreateMonster(targetName); + rec.KillCount++; + state.TotalKills++; + }; + + apply(_sessionState); + apply(_lifetimeState); + _dirty = true; + } + + private void RecordAetheriaSurge(string targetName) + { + if (string.IsNullOrEmpty(targetName)) return; + + Action apply = state => + { + var rec = state.GetOrCreateMonster(targetName); + rec.AetheriaSurges++; + state.TotalAetheriaSurges++; + }; + + apply(_sessionState); + apply(_lifetimeState); + _dirty = true; + } + + private void RecordCloakSurge(string sourceName) + { + if (string.IsNullOrEmpty(sourceName)) return; + + Action apply = state => + { + var rec = state.GetOrCreateMonster(sourceName); + rec.CloakSurges++; + state.TotalCloakSurges++; + }; + + apply(_sessionState); + apply(_lifetimeState); + _dirty = true; + } + + // ═══════════════════════════════════════════════════════════════ + // Regex pattern matching — mirrors Mag-Tools CombatMessages.cs + // ═══════════════════════════════════════════════════════════════ + + // ── Failed attacks (evade/resist) ── + private static readonly Regex[] _failedAttacks = new[] + { + new Regex(@"^You evaded (?.+)!$", RegexOptions.Compiled), + new Regex(@"^(?.+) evaded your attack\.$", RegexOptions.Compiled), + new Regex(@"^You resist the spell cast by (?.+)$", RegexOptions.Compiled), + new Regex(@"^(?.+) resists your spell$", RegexOptions.Compiled), + }; + + private bool TryParseFailedAttack(string text, string myName) + { + foreach (var rx in _failedAttacks) + { + var m = rx.Match(text); + if (!m.Success) continue; + string parsed = m.Groups["name"].Value; + + if (text.StartsWith("You evaded ")) + RecordFailedAttack(parsed, myName, AttackType.MeleeMissile); + else if (text.Contains(" evaded your attack")) + RecordFailedAttack(myName, parsed, AttackType.MeleeMissile); + else if (text.StartsWith("You resist the spell cast by ")) + RecordFailedAttack(parsed, myName, AttackType.Magic); + else if (text.Contains(" resists your spell")) + RecordFailedAttack(myName, parsed, AttackType.Magic); + + return true; + } + return false; + } + + // ── Kill messages — reuse KillTracker patterns ── + private static readonly Regex[] _killPatterns; + + static CombatStatsTracker() + { + // Build compiled kill regexes from KillTracker's string patterns + var field = typeof(KillTracker).GetField("KillPatterns", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + string[] patterns = field?.GetValue(null) as string[] ?? Array.Empty(); + var list = new List(patterns.Length); + foreach (var p in patterns) + list.Add(new Regex(p, RegexOptions.Compiled)); + _killPatterns = list.ToArray(); + } + + private bool TryParseKill(string text, string myName) + { + foreach (var rx in _killPatterns) + { + var m = rx.Match(text); + if (m.Success) + { + RecordKill(m.Groups["targetname"].Value); + return true; + } + } + return false; + } + + // ── Melee/Missile received ── + private static readonly Regex[] _meleeMissileReceived = new[] + { + // Critical hit! Overpower! X smashes your leg for N points of bludgeoning damage! + new Regex(@"^Critical hit! Overpower! (?.+) [\w]+ your .+ for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + // Critical hit! X scratches your leg for N points of slashing damage! + new Regex(@"^Critical hit! (?.+) [\w]+ your .+ for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + // Overpower! X bashes your foot for N points of bludgeoning damage! + new Regex(@"^Overpower! (?.+) [\w]+ your .+ for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + // X grazes your upper arm for N points of bludgeoning damage! + new Regex(@"^(?.+) [\w]+ your .+ for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + }; + + // ── Melee/Missile given ── + private static readonly Regex[] _meleeMissileGiven = new[] + { + // Critical hit! Sneak Attack! Recklessness! You crush X for N points of bludgeoning damage! + new Regex(@"^Critical hit!\s+Sneak Attack! Recklessness! You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + // Critical hit! Sneak Attack! You chill X for N points of cold damage! + new Regex(@"^Critical hit!\s+Sneak Attack! You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + // Critical hit! You scorch X for N points of fire damage! + new Regex(@"^Critical hit!\s+You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + // Sneak Attack! Recklessness! You scratch X for N points of slashing damage! + new Regex(@"^Sneak Attack! Recklessness! You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + // Sneak Attack! You chill X for N points of cold damage! + new Regex(@"^Sneak Attack! You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + // Recklessness! You scratch X for N points of slashing damage! + new Regex(@"^Recklessness! You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + // You scorch X for N points of fire damage! + new Regex(@"^You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + }; + + // ── Magic received ── + private static readonly Regex[] _magicReceived = new[] + { + // Critical hit! Overpower! X shocks you for N points with Incantation of Lightning Arc. + new Regex(@"^Critical hit! Overpower! (?.+) [\w]+ you for (?\d+) point.* with .+$", RegexOptions.Compiled), + // Critical hit! X smashes you for N points with Incantation of Shock Wave Streak. + new Regex(@"^Critical hit! (?.+) [\w]+ you for (?\d+) point.* with .+$", RegexOptions.Compiled), + // Overpower! X shocks you for N points with Incantation of Lightning Arc. + new Regex(@"^Overpower! (?.+) [\w]+ you for (?\d+) point.* with .+$", RegexOptions.Compiled), + // X scorches you for N points with Flame Arc VII. + new Regex(@"^(?.+) [\w]+ you for (?\d+) point.* with .+$", RegexOptions.Compiled), + // Magical energies lose N points of health due to X casting Vitality Siphon + new Regex(@"^Magical energies lose (?\d+) point.* of health due to (?.+) casting .+$", RegexOptions.Compiled), + // You lose N points of health due to X casting Drain Health Other V on you + new Regex(@"^You lose (?\d+) point.* of health due to (?.+) casting .+$", RegexOptions.Compiled), + // X casts Harm Other VI and drains N points ... + new Regex(@"^(?.+) casts .+ and drains (?\d+) point.* .+$", RegexOptions.Compiled), + }; + + // ── Magic given ── + private static readonly Regex[] _magicGiven = new[] + { + // Critical hit! You smash X for N points with Incantation of Shock Wave Streak. + new Regex(@"^Critical hit! You [\w]+ (?.+) for (?\d+) point.* with .+$", RegexOptions.Compiled), + // You nick X for N points with Incantation of Force Bolt. + new Regex(@"^You [\w]+ (?.+) for (?\d+) point.* with .+$", RegexOptions.Compiled), + }; + + // ── Aetheria surges ── + private static readonly Regex _aetheriaSurge = + new Regex(@"^Aetheria surges on (?.+) with the ", RegexOptions.Compiled); + + private bool TryParseAetheriaSurge(string text, string myName) + { + if (!text.StartsWith("Aetheria surges on ")) return false; + var m = _aetheriaSurge.Match(text); + if (!m.Success) return false; + RecordAetheriaSurge(m.Groups["name"].Value); + return true; + } + + // ── Cloak surges ── + private bool TryParseCloakSurge(string text, string myName) + { + // "Your cloak ..." messages indicate our cloak procc'd against an attacker. + // We don't have the attacker name easily, so we track as a global surge count. + if (text.StartsWith("Your cloak") || text.Contains("Shroud of Darkness")) + { + // For cloak surges we store against a synthetic "Cloak Surges" monster key + // since MT tracks them as a total count, not per-monster. + RecordCloakSurge("__cloak_surges__"); + return true; + } + return false; + } + + // ── Generic pattern matcher (returns name + points) ── + private static bool TryMatchPatterns(Regex[] patterns, string text, + out string name, out int points) + { + foreach (var rx in patterns) + { + var m = rx.Match(text); + if (m.Success) + { + name = m.Groups["name"].Value; + int.TryParse(m.Groups["points"].Value, out points); + return true; + } + } + name = null; + points = 0; + return false; + } + + // ═══════════════════════════════════════════════════════════════ + // Element detection — matches Mag-Tools StandardTracker logic + // ═══════════════════════════════════════════════════════════════ + + private static DamageElement GetElementFromText(string text) + { + // Slash + if (text.Contains("slash") || text.Contains(" cut") || text.Contains(" scratch") || text.Contains(" mangle")) + return DamageElement.Slash; + // Pierce + if (text.Contains("pierc") || text.Contains(" gore") || text.Contains(" impale") || text.Contains(" nick") || text.Contains(" stab")) + return DamageElement.Pierce; + // Bludgeon + if (text.Contains("bludge") || text.Contains(" smash") || text.Contains(" bash") || text.Contains(" graze") || text.Contains(" crush")) + return DamageElement.Bludgeon; + // Fire + if (text.Contains("fire") || text.Contains(" burn") || text.Contains(" singe") || text.Contains(" scorch") || text.Contains(" incinerate")) + return DamageElement.Fire; + // Cold + if (text.Contains("cold") || text.Contains(" frost") || text.Contains(" chill") || text.Contains(" numb") || text.Contains(" freeze")) + return DamageElement.Cold; + // Acid + if (text.Contains("acid") || text.Contains(" blister") || text.Contains(" sear") || text.Contains(" corrode") || text.Contains(" dissolve")) + return DamageElement.Acid; + // Electric + if (text.Contains("electric") || text.Contains(" lightning") || text.Contains(" jolt") || text.Contains(" shock") || text.Contains(" spark") || text.Contains(" blast")) + return DamageElement.Electric; + // Nether → Typeless + if (text.Contains(" eradicate") || text.Contains(" wither") || text.Contains(" scar") || text.Contains(" twist")) + return DamageElement.Typeless; + // Life magic drains → Typeless + if (text.Contains(" deplete") || text.Contains(" siphon") || text.Contains(" exhaust") || text.Contains(" drain") || text.Contains(" lose ") || text.Contains(" health ")) + return DamageElement.Typeless; + + return DamageElement.Unknown; + } + + // ═══════════════════════════════════════════════════════════════ + // Chat filtering + // ═══════════════════════════════════════════════════════════════ + + private static bool IsNonCombatChat(string text) + { + // Quick filters to skip non-combat lines before regex work + if (text.StartsWith(" + + diff --git a/MosswartMassacre/PluginCore.cs b/MosswartMassacre/PluginCore.cs index bc11273..fb6c370 100644 --- a/MosswartMassacre/PluginCore.cs +++ b/MosswartMassacre/PluginCore.cs @@ -148,6 +148,7 @@ namespace MosswartMassacre private EquipmentCantripStateTracker _equipmentCantripStateTracker; private NearbyObjectsTracker _nearbyObjectsTracker; private VitalSharingTracker _vitalSharingTracker; + private CombatStatsTracker _combatStatsTracker; private static PluginCore _instance; public static VitalSharingTracker VitalSharingTracker => _instance?._vitalSharingTracker; @@ -347,6 +348,9 @@ namespace MosswartMassacre // Initialize vital sharing tracker (cross-machine vital/debuff coordination) _vitalSharingTracker = new VitalSharingTracker(this); + // Initialize combat stats tracker (Mag-Tools style combat parsing) + _combatStatsTracker = new CombatStatsTracker(this); + // Initialize command router _commandRouter = new CommandRouter(); RegisterCommands(); @@ -460,6 +464,10 @@ namespace MosswartMassacre _vitalSharingTracker?.Dispose(); _vitalSharingTracker = null; + // Stop combat stats tracker + _combatStatsTracker?.Dispose(); + _combatStatsTracker = null; + // Clean up the view ViewManager.ViewDestroy(); //Disable vtank interface @@ -538,6 +546,7 @@ namespace MosswartMassacre _rareTracker = new RareTracker(this); _staticRareTracker = _rareTracker; _chatEventRouter.SetRareTracker(_rareTracker); + _chatEventRouter.SetCombatTracker(_combatStatsTracker); // Apply the values _rareTracker.RareMetaEnabled = PluginSettings.Instance.RareMetaEnabled; @@ -555,6 +564,10 @@ namespace MosswartMassacre WriteToChat("[VitalShare] Enabled at login"); } + // Start combat stats tracker (always on — mirrors Mag-Tools behavior) + _combatStatsTracker?.RestartSession(); + _combatStatsTracker?.Start(); + if (PluginSettings.Instance.AutoUpdateEnabled) { _updateCheckTimer = new Timer(30000); diff --git a/MosswartMassacre/WebSocket.cs b/MosswartMassacre/WebSocket.cs index db6b65b..2a4088b 100644 --- a/MosswartMassacre/WebSocket.cs +++ b/MosswartMassacre/WebSocket.cs @@ -34,7 +34,7 @@ namespace MosswartMassacre private static readonly Uri WsEndpoint = new Uri("wss://overlord.snakedesert.se/websocket/"); private const string SharedSecret = "your_shared_secret"; private const int IntervalSec = 5; - private static string SessionId = ""; + internal static string SessionId = ""; private static IPluginLogger _logger; private static IGameStats _gameStats; @@ -396,6 +396,12 @@ namespace MosswartMassacre await SendEncodedAsync(json, CancellationToken.None); } + public static async Task SendCombatStatsAsync(object payload) + { + var json = JsonConvert.SerializeObject(payload); + await SendEncodedAsync(json, CancellationToken.None); + } + public static async Task SendCharacterStatsAsync(object statsData) { var json = JsonConvert.SerializeObject(statsData); diff --git a/MosswartMassacre/bin/Release/MosswartMassacre.dll b/MosswartMassacre/bin/Release/MosswartMassacre.dll index 70085e5..4eceea1 100644 Binary files a/MosswartMassacre/bin/Release/MosswartMassacre.dll and b/MosswartMassacre/bin/Release/MosswartMassacre.dll differ