Slice 2: telemetry, vitals, chat, spawn/portal, rare, kills and combat stats
The frames the backend reads most, each pinned by a golden test written from the original's source (field order, JSON types, string-formatted numbers, mem_mb in bytes, the PascalCase aggregates Newtonsoft emits for combat_stats). Coordinates repeat the original's float arithmetic from the landblock-local float, so ew/ns carry the same float-widened values as before. Telemetry goes out on connect and every 5 s, vitals every 5 s, combat stats every 10 s when dirty. The rare meta-state switch is not carried over (owner decision); the allegiance rare announcement and the !report reply are. Kill patterns and combat patterns are the original's, unchanged. Also fixes the tick scheduler re-arming repeating jobs from now instead of from their due time, which made every cadence drift a frame late per period. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5dbb294a96
commit
1ff0a40aeb
19 changed files with 1824 additions and 5 deletions
166
src/OpenAC.MosswartMassacre/Trackers/CombatInfo.cs
Normal file
166
src/OpenAC.MosswartMassacre/Trackers/CombatInfo.cs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// The combat_stats wire model, kept exactly as the original declared it:
|
||||
// public fields with snake_case names, enum names as strings, and the
|
||||
// aggregate getters Newtonsoft also emits (the backend receives those too).
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Trackers
|
||||
{
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
394
src/OpenAC.MosswartMassacre/Trackers/CombatStatsTracker.cs
Normal file
394
src/OpenAC.MosswartMassacre/Trackers/CombatStatsTracker.cs
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using OpenAC.MosswartMassacre.Backend;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Trackers;
|
||||
|
||||
/// <summary>
|
||||
/// Parses combat chat into per-monster offense and defense statistics and
|
||||
/// sends a <c>combat_stats</c> snapshot every ten seconds when anything
|
||||
/// changed. The patterns and their order are the original's, which mirror
|
||||
/// Mag-Tools' combat tracker; the backend accumulates the lifetime totals.
|
||||
/// </summary>
|
||||
internal sealed class CombatStatsTracker : IMmFeature
|
||||
{
|
||||
public static readonly TimeSpan SendInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly MmContext _context;
|
||||
private CombatSessionState _sessionState = new();
|
||||
private IDisposable? _sendTimer;
|
||||
private bool _dirty;
|
||||
|
||||
public CombatStatsTracker(MmContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
internal CombatSessionState Session => _sessionState;
|
||||
|
||||
public void OnLogin()
|
||||
{
|
||||
RestartSession();
|
||||
_sendTimer ??= _context.Scheduler.Every(SendInterval, OnSendTick);
|
||||
}
|
||||
|
||||
public void OnLogoff()
|
||||
{
|
||||
_sendTimer?.Dispose();
|
||||
_sendTimer = null;
|
||||
}
|
||||
|
||||
public void Dispose() => OnLogoff();
|
||||
|
||||
public void RestartSession()
|
||||
{
|
||||
_sessionState = new CombatSessionState();
|
||||
_sessionState.Clear();
|
||||
_sessionState.SessionStart = Wire.Timestamp(_context.Clock);
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
public void ProcessChatLine(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
// Chat lines carry tell-link tags and may end in a line break;
|
||||
// strip both so the ^...$ anchors work.
|
||||
text = Regex.Replace(text, "<[^>]+>", "");
|
||||
text = text.TrimEnd('\r', '\n', ' ');
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return;
|
||||
|
||||
if (IsNonCombatChat(text))
|
||||
return;
|
||||
|
||||
bool isCrit = text.Contains("Critical hit!");
|
||||
bool isOverpower = text.Contains("Overpower!");
|
||||
bool isSneakAttack = text.Contains("Sneak Attack!");
|
||||
bool isRecklessness = text.Contains("Recklessness!");
|
||||
|
||||
string myName = _context.CharacterName;
|
||||
|
||||
if (TryParseAetheriaSurge(text))
|
||||
return;
|
||||
|
||||
if (TryParseCloakSurge(text))
|
||||
return;
|
||||
|
||||
if (TryParseFailedAttack(text, myName))
|
||||
return;
|
||||
|
||||
if (TryParseKill(text))
|
||||
return;
|
||||
|
||||
if (TryMatchPatterns(MeleeMissileReceived, text, out string recvSource, out int recvDmg))
|
||||
{
|
||||
RecordHit(recvSource, myName, AttackType.MeleeMissile, GetElementFromText(text),
|
||||
recvDmg, isCrit, isOverpower, isSneakAttack, isRecklessness);
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryMatchPatterns(MeleeMissileGiven, text, out string givenTarget, out int givenDmg))
|
||||
{
|
||||
RecordHit(myName, givenTarget, AttackType.MeleeMissile, GetElementFromText(text),
|
||||
givenDmg, isCrit, isOverpower, isSneakAttack, isRecklessness);
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryMatchPatterns(MagicReceived, text, out string magRecvSource, out int magRecvDmg))
|
||||
{
|
||||
RecordHit(magRecvSource, myName, AttackType.Magic, GetElementFromText(text),
|
||||
magRecvDmg, isCrit, isOverpower, isSneakAttack, isRecklessness);
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryMatchPatterns(MagicGiven, text, out string magGivenTarget, out int magGivenDmg))
|
||||
{
|
||||
RecordHit(myName, magGivenTarget, AttackType.Magic, GetElementFromText(text),
|
||||
magGivenDmg, isCrit, isOverpower, isSneakAttack, isRecklessness);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_context.Verbose($"[CombatStats] Parse error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recording ──
|
||||
|
||||
private void RecordHit(string source, string target, AttackType at, DamageElement el,
|
||||
int damage, bool isCrit, bool isOverpower, bool isSneakAttack, bool isRecklessness)
|
||||
{
|
||||
_ = isOverpower;
|
||||
_ = isSneakAttack;
|
||||
_ = isRecklessness;
|
||||
string myName = _context.CharacterName;
|
||||
bool weAreSource = source.Equals(myName, StringComparison.OrdinalIgnoreCase);
|
||||
string monsterName = weAreSource ? target : source;
|
||||
if (string.IsNullOrEmpty(monsterName))
|
||||
return;
|
||||
|
||||
MonsterCombatRecord rec = _sessionState.GetOrCreateMonster(monsterName);
|
||||
var side = weAreSource ? rec.Offense : rec.Defense;
|
||||
DamageStats 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;
|
||||
_sessionState.TotalDamageGiven += damage;
|
||||
}
|
||||
else
|
||||
{
|
||||
rec.DamageReceived += damage;
|
||||
_sessionState.TotalDamageReceived += damage;
|
||||
}
|
||||
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
private void RecordFailedAttack(string source, string target, AttackType at)
|
||||
{
|
||||
string myName = _context.CharacterName;
|
||||
bool weAreSource = source.Equals(myName, StringComparison.OrdinalIgnoreCase);
|
||||
string monsterName = weAreSource ? target : source;
|
||||
if (string.IsNullOrEmpty(monsterName))
|
||||
return;
|
||||
|
||||
MonsterCombatRecord rec = _sessionState.GetOrCreateMonster(monsterName);
|
||||
var side = weAreSource ? rec.Offense : rec.Defense;
|
||||
DamageStats stats = rec.GetOrCreateStats(side, at, DamageElement.None);
|
||||
stats.TotalAttacks++;
|
||||
stats.FailedAttacks++;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
private void RecordKill(string targetName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(targetName))
|
||||
return;
|
||||
MonsterCombatRecord rec = _sessionState.GetOrCreateMonster(targetName);
|
||||
rec.KillCount++;
|
||||
_sessionState.TotalKills++;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
private void RecordAetheriaSurge(string targetName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(targetName))
|
||||
return;
|
||||
MonsterCombatRecord rec = _sessionState.GetOrCreateMonster(targetName);
|
||||
rec.AetheriaSurges++;
|
||||
_sessionState.TotalAetheriaSurges++;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
private void RecordCloakSurge(string sourceName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sourceName))
|
||||
return;
|
||||
MonsterCombatRecord rec = _sessionState.GetOrCreateMonster(sourceName);
|
||||
rec.CloakSurges++;
|
||||
_sessionState.TotalCloakSurges++;
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
// ── Patterns, as the original listed them ──
|
||||
|
||||
private static readonly Regex[] FailedAttacks =
|
||||
[
|
||||
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 (Regex rx in FailedAttacks)
|
||||
{
|
||||
Match m = rx.Match(text);
|
||||
if (!m.Success)
|
||||
continue;
|
||||
string parsed = m.Groups["name"].Value;
|
||||
|
||||
if (text.StartsWith("You evaded ", StringComparison.Ordinal))
|
||||
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 ", StringComparison.Ordinal))
|
||||
RecordFailedAttack(parsed, myName, AttackType.Magic);
|
||||
else if (text.Contains(" resists your spell"))
|
||||
RecordFailedAttack(myName, parsed, AttackType.Magic);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static readonly Regex[] KillPatterns =
|
||||
[.. KillTracker.KillPatterns.Select(pattern => new Regex(pattern, RegexOptions.Compiled))];
|
||||
|
||||
private bool TryParseKill(string text)
|
||||
{
|
||||
foreach (Regex rx in KillPatterns)
|
||||
{
|
||||
Match m = rx.Match(text);
|
||||
if (m.Success)
|
||||
{
|
||||
RecordKill(m.Groups["targetname"].Value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static readonly Regex[] MeleeMissileReceived =
|
||||
[
|
||||
new Regex(@"^Critical hit! Overpower! (?<name>.+) [\w]+ your .+ for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
new Regex(@"^Critical hit! (?<name>.+) [\w]+ your .+ for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
new Regex(@"^Overpower! (?<name>.+) [\w]+ your .+ for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
new Regex(@"^(?<name>.+) [\w]+ your .+ for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
];
|
||||
|
||||
private static readonly Regex[] MeleeMissileGiven =
|
||||
[
|
||||
new Regex(@"^Critical hit!\s+Sneak Attack! Recklessness! You [\w]+ (?<name>.+) for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
new Regex(@"^Critical hit!\s+Sneak Attack! You [\w]+ (?<name>.+) for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
new Regex(@"^Critical hit!\s+You [\w]+ (?<name>.+) for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
new Regex(@"^Sneak Attack! Recklessness! You [\w]+ (?<name>.+) for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
new Regex(@"^Sneak Attack! You [\w]+ (?<name>.+) for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
new Regex(@"^Recklessness! You [\w]+ (?<name>.+) for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
new Regex(@"^You [\w]+ (?<name>.+) for (?<points>\d+) point.* of .+ damage", RegexOptions.Compiled),
|
||||
];
|
||||
|
||||
private static readonly Regex[] MagicReceived =
|
||||
[
|
||||
new Regex(@"^Critical hit! Overpower! (?<name>.+) [\w]+ you for (?<points>\d+) point.* with .+$", RegexOptions.Compiled),
|
||||
new Regex(@"^Critical hit! (?<name>.+) [\w]+ you for (?<points>\d+) point.* with .+$", RegexOptions.Compiled),
|
||||
new Regex(@"^Overpower! (?<name>.+) [\w]+ you for (?<points>\d+) point.* with .+$", RegexOptions.Compiled),
|
||||
new Regex(@"^(?<name>.+) [\w]+ you for (?<points>\d+) point.* with .+$", RegexOptions.Compiled),
|
||||
new Regex(@"^Magical energies lose (?<points>\d+) point.* of health due to (?<name>.+) casting .+$", RegexOptions.Compiled),
|
||||
new Regex(@"^You lose (?<points>\d+) point.* of health due to (?<name>.+) casting .+$", RegexOptions.Compiled),
|
||||
new Regex(@"^(?<name>.+) casts .+ and drains (?<points>\d+) point.* .+$", RegexOptions.Compiled),
|
||||
];
|
||||
|
||||
private static readonly Regex[] MagicGiven =
|
||||
[
|
||||
new Regex(@"^Critical hit! You [\w]+ (?<name>.+) for (?<points>\d+) point.* with .+$", RegexOptions.Compiled),
|
||||
new Regex(@"^You [\w]+ (?<name>.+) for (?<points>\d+) point.* with .+$", RegexOptions.Compiled),
|
||||
];
|
||||
|
||||
private static readonly Regex AetheriaSurge =
|
||||
new(@"^Aetheria surges on (?<name>.+) with the ", RegexOptions.Compiled);
|
||||
|
||||
private bool TryParseAetheriaSurge(string text)
|
||||
{
|
||||
if (!text.StartsWith("Aetheria surges on ", StringComparison.Ordinal))
|
||||
return false;
|
||||
Match m = AetheriaSurge.Match(text);
|
||||
if (!m.Success)
|
||||
return false;
|
||||
RecordAetheriaSurge(m.Groups["name"].Value);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryParseCloakSurge(string text)
|
||||
{
|
||||
// Cloak surges are tracked as one total under a synthetic monster key.
|
||||
if (text.StartsWith("Your cloak", StringComparison.Ordinal) || text.Contains("Shroud of Darkness"))
|
||||
{
|
||||
RecordCloakSurge("__cloak_surges__");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryMatchPatterns(Regex[] patterns, string text, out string name, out int points)
|
||||
{
|
||||
foreach (Regex rx in patterns)
|
||||
{
|
||||
Match m = rx.Match(text);
|
||||
if (m.Success)
|
||||
{
|
||||
name = m.Groups["name"].Value;
|
||||
int.TryParse(m.Groups["points"].Value, out points);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
name = string.Empty;
|
||||
points = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static DamageElement GetElementFromText(string text)
|
||||
{
|
||||
if (text.Contains("slash") || text.Contains(" cut") || text.Contains(" scratch") || text.Contains(" mangle"))
|
||||
return DamageElement.Slash;
|
||||
if (text.Contains("pierc") || text.Contains(" gore") || text.Contains(" impale") || text.Contains(" nick") || text.Contains(" stab"))
|
||||
return DamageElement.Pierce;
|
||||
if (text.Contains("bludge") || text.Contains(" smash") || text.Contains(" bash") || text.Contains(" graze") || text.Contains(" crush"))
|
||||
return DamageElement.Bludgeon;
|
||||
if (text.Contains("fire") || text.Contains(" burn") || text.Contains(" singe") || text.Contains(" scorch") || text.Contains(" incinerate"))
|
||||
return DamageElement.Fire;
|
||||
if (text.Contains("cold") || text.Contains(" frost") || text.Contains(" chill") || text.Contains(" numb") || text.Contains(" freeze"))
|
||||
return DamageElement.Cold;
|
||||
if (text.Contains("acid") || text.Contains(" blister") || text.Contains(" sear") || text.Contains(" corrode") || text.Contains(" dissolve"))
|
||||
return DamageElement.Acid;
|
||||
if (text.Contains("electric") || text.Contains(" lightning") || text.Contains(" jolt") || text.Contains(" shock") || text.Contains(" spark") || text.Contains(" blast"))
|
||||
return DamageElement.Electric;
|
||||
if (text.Contains(" eradicate") || text.Contains(" wither") || text.Contains(" scar") || text.Contains(" twist"))
|
||||
return DamageElement.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;
|
||||
}
|
||||
|
||||
private static bool IsNonCombatChat(string text)
|
||||
{
|
||||
if (text.StartsWith("<Tell:", StringComparison.Ordinal) || text.StartsWith('['))
|
||||
return true;
|
||||
if (text.StartsWith("You say,", StringComparison.Ordinal) || text.StartsWith("You tell ", StringComparison.Ordinal))
|
||||
return true;
|
||||
if (text.Contains(" tells you,") || text.Contains(" says,"))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Sending ──
|
||||
|
||||
/// <summary>The ten-second send: one snapshot when anything changed since the last.</summary>
|
||||
internal void OnSendTick()
|
||||
{
|
||||
if (!_dirty)
|
||||
return;
|
||||
_dirty = false;
|
||||
|
||||
var payload = new
|
||||
{
|
||||
type = "combat_stats",
|
||||
timestamp = Wire.Timestamp(_context.Clock),
|
||||
character_name = _context.CharacterName,
|
||||
session_id = _context.ConnectionSessionId ?? "",
|
||||
session = _sessionState,
|
||||
lifetime = (object?)null,
|
||||
};
|
||||
_context.Send(payload);
|
||||
}
|
||||
}
|
||||
119
src/OpenAC.MosswartMassacre/Trackers/KillTracker.cs
Normal file
119
src/OpenAC.MosswartMassacre/Trackers/KillTracker.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using OpenAC.MosswartMassacre.Infrastructure;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Trackers;
|
||||
|
||||
/// <summary>
|
||||
/// Kills, deaths and kill rate for the session, from the player's own kill
|
||||
/// messages. The pattern list is the original's, unchanged.
|
||||
/// </summary>
|
||||
internal sealed class KillTracker(IClock clock)
|
||||
{
|
||||
// Kill message patterns, preserved exactly.
|
||||
internal static readonly string[] KillPatterns =
|
||||
[
|
||||
@"^You flatten (?<targetname>.+)'s body with the force of your assault!$",
|
||||
@"^You bring (?<targetname>.+) to a fiery end!$",
|
||||
@"^You beat (?<targetname>.+) to a lifeless pulp!$",
|
||||
@"^You smite (?<targetname>.+) mightily!$",
|
||||
@"^You obliterate (?<targetname>.+)!$",
|
||||
@"^You run (?<targetname>.+) through!$",
|
||||
@"^You reduce (?<targetname>.+) to a sizzling, oozing mass!$",
|
||||
@"^You knock (?<targetname>.+) into next Morningthaw!$",
|
||||
@"^You split (?<targetname>.+) apart!$",
|
||||
@"^You cleave (?<targetname>.+) in twain!$",
|
||||
@"^You slay (?<targetname>.+) viciously enough to impart death several times over!$",
|
||||
@"^You reduce (?<targetname>.+) to a drained, twisted corpse!$",
|
||||
@"^Your killing blow nearly turns (?<targetname>.+) inside-out!$",
|
||||
@"^Your attack stops (?<targetname>.+) cold!$",
|
||||
@"^Your lightning coruscates over (?<targetname>.+)'s mortal remains!$",
|
||||
@"^Your assault sends (?<targetname>.+) to an icy death!$",
|
||||
@"^You killed (?<targetname>.+)!$",
|
||||
@"^The thunder of crushing (?<targetname>.+) is followed by the deafening silence of death!$",
|
||||
@"^The deadly force of your attack is so strong that (?<targetname>.+)'s ancestors feel it!$",
|
||||
@"^(?<targetname>.+)'s seared corpse smolders before you!$",
|
||||
@"^(?<targetname>.+) is reduced to cinders!$",
|
||||
@"^(?<targetname>.+) is shattered by your assault!$",
|
||||
@"^(?<targetname>.+) catches your attack, with dire consequences!$",
|
||||
@"^(?<targetname>.+) is utterly destroyed by your attack!$",
|
||||
@"^(?<targetname>.+) suffers a frozen fate!$",
|
||||
@"^(?<targetname>.+)'s perforated corpse falls before you!$",
|
||||
@"^(?<targetname>.+) is fatally punctured!$",
|
||||
@"^(?<targetname>.+)'s death is preceded by a sharp, stabbing pain!$",
|
||||
@"^(?<targetname>.+) is torn to ribbons by your assault!$",
|
||||
@"^(?<targetname>.+) is liquified by your attack!$",
|
||||
@"^(?<targetname>.+)'s last strength dissolves before you!$",
|
||||
@"^Electricity tears (?<targetname>.+) apart!$",
|
||||
@"^Blistered by lightning, (?<targetname>.+) falls!$",
|
||||
@"^(?<targetname>.+)'s last strength withers before you!$",
|
||||
@"^(?<targetname>.+) is dessicated by your attack!$",
|
||||
@"^(?<targetname>.+) is incinerated by your assault!$",
|
||||
];
|
||||
|
||||
private static readonly Regex[] Compiled =
|
||||
[.. KillPatterns.Select(pattern => new Regex(pattern, RegexOptions.Compiled))];
|
||||
|
||||
public int TotalKills { get; private set; }
|
||||
|
||||
public double KillsPerHour { get; private set; }
|
||||
|
||||
public double KillsPer5Min { get; private set; }
|
||||
|
||||
public int SessionDeaths { get; private set; }
|
||||
|
||||
public int TotalDeaths { get; private set; }
|
||||
|
||||
public DateTime StatsStartTime { get; private set; } = clock.Now;
|
||||
|
||||
public DateTime LastKillTime { get; private set; } = clock.Now;
|
||||
|
||||
public int RareCount { get; set; }
|
||||
|
||||
/// <summary>Counts a kill when <paramref name="text"/> is one of the player's kill messages.</summary>
|
||||
public bool CheckForKill(string text)
|
||||
{
|
||||
if (!IsKilledByMeMessage(text))
|
||||
return false;
|
||||
TotalKills++;
|
||||
LastKillTime = clock.Now;
|
||||
CalculateKillsPerInterval();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void OnDeath() => SessionDeaths++;
|
||||
|
||||
public void SetTotalDeaths(int totalDeaths) => TotalDeaths = totalDeaths;
|
||||
|
||||
public void RestartStats()
|
||||
{
|
||||
TotalKills = 0;
|
||||
RareCount = 0;
|
||||
SessionDeaths = 0;
|
||||
StatsStartTime = clock.Now;
|
||||
KillsPer5Min = 0;
|
||||
KillsPerHour = 0;
|
||||
}
|
||||
|
||||
/// <summary>The once-a-second refresh the original ran on its stats timer.</summary>
|
||||
public void Update() => CalculateKillsPerInterval();
|
||||
|
||||
private void CalculateKillsPerInterval()
|
||||
{
|
||||
double minutesElapsed = (clock.Now - StatsStartTime).TotalMinutes;
|
||||
if (minutesElapsed > 0)
|
||||
{
|
||||
KillsPer5Min = (TotalKills / minutesElapsed) * 5;
|
||||
KillsPerHour = (TotalKills / minutesElapsed) * 60;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsKilledByMeMessage(string text)
|
||||
{
|
||||
foreach (Regex pattern in Compiled)
|
||||
{
|
||||
if (pattern.IsMatch(text))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
48
src/OpenAC.MosswartMassacre/Trackers/RareTracker.cs
Normal file
48
src/OpenAC.MosswartMassacre/Trackers/RareTracker.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using OpenAC.MosswartMassacre.Backend;
|
||||
using OpenAC.MosswartMassacre.Infrastructure;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Trackers;
|
||||
|
||||
/// <summary>
|
||||
/// The player's own rare discoveries: counts them, announces the rare in
|
||||
/// allegiance chat three seconds later and sends the <c>rare</c> frame. (The
|
||||
/// original could also switch the bot's meta state on a rare; that is not
|
||||
/// carried over.)
|
||||
/// </summary>
|
||||
internal sealed class RareTracker(MmContext context, DelayedCommands delayed)
|
||||
{
|
||||
private static readonly Regex RarePattern =
|
||||
new(@"^(?<name>['A-Za-z ]+)\shas discovered the (?<item>.*?)!$", RegexOptions.Compiled);
|
||||
|
||||
public int RareCount { get; set; }
|
||||
|
||||
public bool CheckForRare(string text, out string? rareText)
|
||||
{
|
||||
rareText = null;
|
||||
Match match = RarePattern.Match(text);
|
||||
if (!match.Success || match.Groups["name"].Value != context.CharacterName)
|
||||
return false;
|
||||
|
||||
rareText = match.Groups["item"].Value;
|
||||
RareCount++;
|
||||
delayed.Add($"/a {rareText}", 3000);
|
||||
SendRare(rareText);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SendRare(string rare)
|
||||
{
|
||||
MmCoordinates coords = MmCoordinates.From(context.Automation.Navigation.Snapshot.Position);
|
||||
context.Send(new
|
||||
{
|
||||
type = "rare",
|
||||
timestamp = Wire.Timestamp(context.Clock),
|
||||
character_name = context.CharacterName,
|
||||
name = rare,
|
||||
ew = coords.EW,
|
||||
ns = coords.NS,
|
||||
z = coords.Z,
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue