The plugin's _lifetimeState was always identical to _sessionState because both started fresh on every load/login and accumulated the same events. Lifetime needs persistence across sessions. Fix: plugin now only maintains _sessionState and sends lifetime=null. The backend computes deltas between consecutive session snapshots and accumulates them into a persisted lifetime in the combat_stats DB table. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
569 lines
28 KiB
C#
569 lines
28 KiB
C#
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
|
|
/// - Lifetime: accumulated on the backend by merging session deltas
|
|
///
|
|
/// 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 System.Windows.Forms.Timer _sendTimer;
|
|
private bool _dirty;
|
|
private bool _disposed;
|
|
|
|
public CombatStatsTracker(IPluginLogger logger)
|
|
{
|
|
_logger = logger;
|
|
_sessionState.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();
|
|
_logger?.Log("[CombatStats] Tracker started (10s send interval)");
|
|
}
|
|
|
|
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
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
private int _parseCount;
|
|
|
|
public void ProcessChatLine(string text)
|
|
{
|
|
if (string.IsNullOrEmpty(text)) return;
|
|
|
|
try
|
|
{
|
|
// DECAL ChatBoxMessage delivers raw text with HTML tags and
|
|
// trailing newlines. Strip them so ^...$ regex anchors work.
|
|
text = System.Text.RegularExpressions.Regex.Replace(text, "<[^>]+>", "");
|
|
text = text.TrimEnd('\r', '\n', ' ');
|
|
if (string.IsNullOrEmpty(text)) return;
|
|
|
|
// Skip non-combat chat (tells, channels, etc.)
|
|
if (IsNonCombatChat(text)) return;
|
|
|
|
// Diagnostic: log first few parses to verify the pipeline is live
|
|
if (_parseCount < 3)
|
|
{
|
|
_logger?.Log($"[CombatStats] ProcessChatLine invoked (#{_parseCount}): \"{text.Substring(0, Math.Min(text.Length, 80))}\"");
|
|
_parseCount++;
|
|
}
|
|
|
|
// 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);
|
|
|
|
_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);
|
|
|
|
_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);
|
|
|
|
_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);
|
|
|
|
_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);
|
|
|
|
_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 — same patterns as KillTracker ──
|
|
private static readonly Regex[] _killPatterns = new[]
|
|
{
|
|
new Regex(@"^You flatten (?<targetname>.+)'s body with the force of your assault!$", RegexOptions.Compiled),
|
|
new Regex(@"^You bring (?<targetname>.+) to a fiery end!$", RegexOptions.Compiled),
|
|
new Regex(@"^You beat (?<targetname>.+) to a lifeless pulp!$", RegexOptions.Compiled),
|
|
new Regex(@"^You smite (?<targetname>.+) mightily!$", RegexOptions.Compiled),
|
|
new Regex(@"^You obliterate (?<targetname>.+)!$", RegexOptions.Compiled),
|
|
new Regex(@"^You run (?<targetname>.+) through!$", RegexOptions.Compiled),
|
|
new Regex(@"^You reduce (?<targetname>.+) to a sizzling, oozing mass!$", RegexOptions.Compiled),
|
|
new Regex(@"^You knock (?<targetname>.+) into next Morningthaw!$", RegexOptions.Compiled),
|
|
new Regex(@"^You split (?<targetname>.+) apart!$", RegexOptions.Compiled),
|
|
new Regex(@"^You cleave (?<targetname>.+) in twain!$", RegexOptions.Compiled),
|
|
new Regex(@"^You slay (?<targetname>.+) viciously enough to impart death several times over!$", RegexOptions.Compiled),
|
|
new Regex(@"^You reduce (?<targetname>.+) to a drained, twisted corpse!$", RegexOptions.Compiled),
|
|
new Regex(@"^Your killing blow nearly turns (?<targetname>.+) inside-out!$", RegexOptions.Compiled),
|
|
new Regex(@"^Your attack stops (?<targetname>.+) cold!$", RegexOptions.Compiled),
|
|
new Regex(@"^Your lightning coruscates over (?<targetname>.+)'s mortal remains!$", RegexOptions.Compiled),
|
|
new Regex(@"^Your assault sends (?<targetname>.+) to an icy death!$", RegexOptions.Compiled),
|
|
new Regex(@"^You killed (?<targetname>.+)!$", RegexOptions.Compiled),
|
|
new Regex(@"^The thunder of crushing (?<targetname>.+) is followed by the deafening silence of death!$", RegexOptions.Compiled),
|
|
new Regex(@"^The deadly force of your attack is so strong that (?<targetname>.+)'s ancestors feel it!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+)'s seared corpse smolders before you!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+) is reduced to cinders!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+) is shattered by your assault!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+) catches your attack, with dire consequences!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+) is utterly destroyed by your attack!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+) suffers a frozen fate!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+)'s perforated corpse falls before you!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+) is fatally punctured!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+)'s death is preceded by a sharp, stabbing pain!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+) is torn to ribbons by your assault!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+) is liquified by your attack!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+)'s last strength dissolves before you!$", RegexOptions.Compiled),
|
|
new Regex(@"^Electricity tears (?<targetname>.+) apart!$", RegexOptions.Compiled),
|
|
new Regex(@"^Blistered by lightning, (?<targetname>.+) falls!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+)'s last strength withers before you!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+) is dessicated by your attack!$", RegexOptions.Compiled),
|
|
new Regex(@"^(?<targetname>.+) is incinerated by your assault!$", RegexOptions.Compiled),
|
|
};
|
|
|
|
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 int _sendCount;
|
|
|
|
private void OnSendTick(object sender, EventArgs e)
|
|
{
|
|
if (!_dirty) return;
|
|
_dirty = false;
|
|
_sendCount++;
|
|
if (_sendCount <= 3)
|
|
_logger?.Log($"[CombatStats] Sending snapshot #{_sendCount}: {_sessionState.TotalKills} kills, {_sessionState.TotalDamageGiven} dmg given, {_sessionState.Monsters.Count} monsters");
|
|
|
|
try
|
|
{
|
|
var payload = new
|
|
{
|
|
type = "combat_stats",
|
|
timestamp = DateTime.UtcNow.ToString("o"),
|
|
character_name = SafeCharacterName(),
|
|
session_id = WebSocket.SessionId ?? "",
|
|
session = _sessionState,
|
|
lifetime = (object)null, // lifetime is accumulated on the backend
|
|
};
|
|
_ = 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();
|
|
}
|
|
}
|
|
}
|