feat: Mag-Tools style combat stats tracking and streaming
Adds a full combat event parser to the plugin that replicates every stat Mag-Tools' Combat Tracker shows: - CombatInfo.cs: DamageElement/AttackType enums, DamageStats class (TotalAttacks, FailedAttacks, Crits, Normal/Crit damage + max), MonsterCombatRecord with nested offense/defense dictionaries (AttackType → DamageElement → DamageStats), CombatSessionState. - CombatStatsTracker.cs: parses combat chat using the same regex patterns as Mag-Tools' StandardTracker + AetheriaTracker + CloakTracker. Detects melee/missile/magic hits (given+received), evades, resists, kill messages, aetheria surges, cloak surges. Element detection via verb keyword matching. Maintains session (cleared on login) and lifetime (never cleared) state. Sends a combat_stats snapshot to Overlord every 10 seconds. - ChatEventRouter: wired to call ProcessChatLine on every chat event - PluginCore: instantiates tracker, starts on login, disposes on shutdown - WebSocket: added SendCombatStatsAsync + made SessionId internal Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e59e2cdfc3
commit
38a14c5894
7 changed files with 711 additions and 1 deletions
|
|
@ -14,10 +14,12 @@ namespace MosswartMassacre
|
|||
private readonly IPluginLogger _logger;
|
||||
private readonly KillTracker _killTracker;
|
||||
private RareTracker _rareTracker;
|
||||
private CombatStatsTracker _combatTracker;
|
||||
private readonly Action<int> _onRareCountChanged;
|
||||
private readonly Action<string> _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))
|
||||
{
|
||||
|
|
|
|||
162
MosswartMassacre/CombatInfo.cs
Normal file
162
MosswartMassacre/CombatInfo.cs
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-element damage statistics within a single attack type.
|
||||
/// Mirrors Mag-Tools CombatInfo.DamageByAttackType.DamageByElement.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<AttackType, Dictionary<DamageElement, DamageStats>> Offense
|
||||
= new Dictionary<AttackType, Dictionary<DamageElement, DamageStats>>();
|
||||
|
||||
// defense[AttackType][DamageElement] = stats for damage WE received
|
||||
[JsonProperty("defense")]
|
||||
public Dictionary<AttackType, Dictionary<DamageElement, DamageStats>> Defense
|
||||
= new Dictionary<AttackType, Dictionary<DamageElement, DamageStats>>();
|
||||
|
||||
public DamageStats GetOrCreateStats(
|
||||
Dictionary<AttackType, Dictionary<DamageElement, DamageStats>> side,
|
||||
AttackType at, DamageElement el)
|
||||
{
|
||||
if (!side.TryGetValue(at, out var byElement))
|
||||
{
|
||||
byElement = new Dictionary<DamageElement, DamageStats>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds all combat data for a single session (or lifetime accumulation).
|
||||
/// Serializes cleanly to JSON for the WebSocket combat_stats envelope.
|
||||
/// </summary>
|
||||
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<string, MonsterCombatRecord> Monsters
|
||||
= new Dictionary<string, MonsterCombatRecord>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
524
MosswartMassacre/CombatStatsTracker.cs
Normal file
524
MosswartMassacre/CombatStatsTracker.cs
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using Decal.Adapter;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace MosswartMassacre
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<CombatSessionState> 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<CombatSessionState> 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<CombatSessionState> 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<CombatSessionState> 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<CombatSessionState> 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 (?<name>.+)!$", RegexOptions.Compiled),
|
||||
new Regex(@"^(?<name>.+) evaded your attack\.$", RegexOptions.Compiled),
|
||||
new Regex(@"^You resist the spell cast by (?<name>.+)$", RegexOptions.Compiled),
|
||||
new Regex(@"^(?<name>.+) 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<string>();
|
||||
var list = new List<Regex>(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! (?<name>.+) [\w]+ your .+ for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
// Critical hit! X scratches your leg for N points of slashing damage!
|
||||
new Regex(@"^Critical hit! (?<name>.+) [\w]+ your .+ for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
// Overpower! X bashes your foot for N points of bludgeoning damage!
|
||||
new Regex(@"^Overpower! (?<name>.+) [\w]+ your .+ for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
// X grazes your upper arm for N points of bludgeoning damage!
|
||||
new Regex(@"^(?<name>.+) [\w]+ your .+ for (?<points>\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]+ (?<name>.+) for (?<points>\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]+ (?<name>.+) for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
// Critical hit! You scorch X for N points of fire damage!
|
||||
new Regex(@"^Critical hit!\s+You [\w]+ (?<name>.+) for (?<points>\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]+ (?<name>.+) for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
// Sneak Attack! You chill X for N points of cold damage!
|
||||
new Regex(@"^Sneak Attack! You [\w]+ (?<name>.+) for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
// Recklessness! You scratch X for N points of slashing damage!
|
||||
new Regex(@"^Recklessness! You [\w]+ (?<name>.+) for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
// You scorch X for N points of fire damage!
|
||||
new Regex(@"^You [\w]+ (?<name>.+) for (?<points>\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! (?<name>.+) [\w]+ you for (?<points>\d+) point.* with .+$", RegexOptions.Compiled),
|
||||
// Critical hit! X smashes you for N points with Incantation of Shock Wave Streak.
|
||||
new Regex(@"^Critical hit! (?<name>.+) [\w]+ you for (?<points>\d+) point.* with .+$", RegexOptions.Compiled),
|
||||
// Overpower! X shocks you for N points with Incantation of Lightning Arc.
|
||||
new Regex(@"^Overpower! (?<name>.+) [\w]+ you for (?<points>\d+) point.* with .+$", RegexOptions.Compiled),
|
||||
// X scorches you for N points with Flame Arc VII.
|
||||
new Regex(@"^(?<name>.+) [\w]+ you for (?<points>\d+) point.* with .+$", RegexOptions.Compiled),
|
||||
// Magical energies lose N points of health due to X casting Vitality Siphon
|
||||
new Regex(@"^Magical energies lose (?<points>\d+) point.* of health due to (?<name>.+) casting .+$", RegexOptions.Compiled),
|
||||
// You lose N points of health due to X casting Drain Health Other V on you
|
||||
new Regex(@"^You lose (?<points>\d+) point.* of health due to (?<name>.+) casting .+$", RegexOptions.Compiled),
|
||||
// X casts Harm Other VI and drains N points ...
|
||||
new Regex(@"^(?<name>.+) casts .+ and drains (?<points>\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]+ (?<name>.+) for (?<points>\d+) point.* with .+$", RegexOptions.Compiled),
|
||||
// You nick X for N points with Incantation of Force Bolt.
|
||||
new Regex(@"^You [\w]+ (?<name>.+) for (?<points>\d+) point.* with .+$", RegexOptions.Compiled),
|
||||
};
|
||||
|
||||
// ── Aetheria surges ──
|
||||
private static readonly Regex _aetheriaSurge =
|
||||
new Regex(@"^Aetheria surges on (?<name>.+) 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("<Tell:") || text.StartsWith("[")) return true;
|
||||
if (text.StartsWith("You say,") || text.StartsWith("You tell ")) return true;
|
||||
if (text.Contains(" tells you,") || text.Contains(" says,")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Periodic send
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private void OnSendTick(object sender, EventArgs e)
|
||||
{
|
||||
if (!_dirty) return;
|
||||
_dirty = false;
|
||||
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
type = "combat_stats",
|
||||
timestamp = DateTime.UtcNow.ToString("o"),
|
||||
character_name = SafeCharacterName(),
|
||||
session_id = WebSocket.SessionId ?? "",
|
||||
session = _sessionState,
|
||||
lifetime = _lifetimeState,
|
||||
};
|
||||
_ = WebSocket.SendCombatStatsAsync(payload);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Log($"[CombatStats] Send error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
private static string SafeCharacterName()
|
||||
{
|
||||
try { return CoreManager.Current.CharacterFilter?.Name ?? ""; }
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -352,6 +352,8 @@
|
|||
<Compile Include="Views\VVSTabbedMainView.cs" />
|
||||
<Compile Include="Views\VitalSharingOverlayView.cs" />
|
||||
<Compile Include="CharacterStats.cs" />
|
||||
<Compile Include="CombatInfo.cs" />
|
||||
<Compile Include="CombatStatsTracker.cs" />
|
||||
<Compile Include="DungeonMapReader.cs" />
|
||||
<Compile Include="NearbyObjectsTracker.cs" />
|
||||
<Compile Include="VitalSharingTracker.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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue