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
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.cs diff=csharp
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
using OpenAC.MosswartMassacre.Backend;
|
using OpenAC.MosswartMassacre.Backend;
|
||||||
|
using OpenAC.MosswartMassacre.Infrastructure;
|
||||||
|
using OpenAC.MosswartMassacre.Streams;
|
||||||
|
using OpenAC.MosswartMassacre.Trackers;
|
||||||
|
|
||||||
namespace OpenAC.MosswartMassacre;
|
namespace OpenAC.MosswartMassacre;
|
||||||
|
|
||||||
|
|
@ -10,8 +13,20 @@ internal static class FeatureCatalog
|
||||||
{
|
{
|
||||||
public static void AddFeatures(MmContext context, BackendSession session, List<IMmFeature> features)
|
public static void AddFeatures(MmContext context, BackendSession session, List<IMmFeature> features)
|
||||||
{
|
{
|
||||||
_ = context;
|
// Streams and session counters.
|
||||||
_ = session;
|
var delayed = new DelayedCommands(context);
|
||||||
_ = features;
|
var kills = new KillTracker(context.Clock);
|
||||||
|
var rares = new RareTracker(context, delayed);
|
||||||
|
var combat = new CombatStatsTracker(context);
|
||||||
|
var stats = new SessionStats(context, kills, rares);
|
||||||
|
var telemetry = new TelemetryStream(context, stats);
|
||||||
|
features.Add(stats);
|
||||||
|
features.Add(combat);
|
||||||
|
features.Add(new ChatRouter(context, kills, combat, rares));
|
||||||
|
features.Add(telemetry);
|
||||||
|
features.Add(new VitalsStream(context));
|
||||||
|
features.Add(new SpawnPortalStream(context));
|
||||||
|
features.Add(new BotControl(context));
|
||||||
|
session.AddConnectionListener(telemetry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
namespace OpenAC.MosswartMassacre.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a chat command after a delay, through the chat bar as if typed, so
|
||||||
|
/// other plugins' verbs see it too. The original used this to announce a rare
|
||||||
|
/// in allegiance chat three seconds after finding it.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DelayedCommands(MmContext context)
|
||||||
|
{
|
||||||
|
private readonly List<IDisposable> _pending = [];
|
||||||
|
|
||||||
|
public void Add(string command, double delayMilliseconds)
|
||||||
|
{
|
||||||
|
IDisposable? handle = null;
|
||||||
|
handle = context.Scheduler.Delay(TimeSpan.FromMilliseconds(delayMilliseconds), () =>
|
||||||
|
{
|
||||||
|
if (handle is not null)
|
||||||
|
_pending.Remove(handle);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.Automation.Chat.Submit(command);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
context.Chat.Write("Error in delayed command system: " + ex.Message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_pending.Add(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops every command not yet run.</summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
foreach (IDisposable handle in _pending)
|
||||||
|
handle.Dispose();
|
||||||
|
_pending.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/OpenAC.MosswartMassacre/Infrastructure/MmCoordinates.cs
Normal file
62
src/OpenAC.MosswartMassacre/Infrastructure/MmCoordinates.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Map coordinates computed exactly as the original computed them for the
|
||||||
|
/// telemetry, rare and sharing frames.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The original read the player's landblock-local position as three floats and
|
||||||
|
/// converted with single-precision steps, so its east-west and north-south
|
||||||
|
/// values are floats widened to double. The backend receives those exact
|
||||||
|
/// numbers; recomputing them from the host's double-precision coordinate would
|
||||||
|
/// differ in the last digits. This type recovers the landblock-local float the
|
||||||
|
/// original read and repeats its arithmetic step for step.
|
||||||
|
/// </remarks>
|
||||||
|
internal readonly record struct MmCoordinates(double EW, double NS, double Z)
|
||||||
|
{
|
||||||
|
/// <summary>The original's conversion of a position, float steps included.</summary>
|
||||||
|
public static MmCoordinates From(in PluginNavigationPosition position)
|
||||||
|
{
|
||||||
|
uint cell = position.CellId;
|
||||||
|
uint blockX = (cell >> 24) & 0xFFu;
|
||||||
|
uint blockY = (cell >> 16) & 0xFFu;
|
||||||
|
|
||||||
|
// The host's map coordinate is ((block - 127) * 192 + local - 84) / 240
|
||||||
|
// metres in doubles; inverting it and narrowing to float gives back the
|
||||||
|
// float the original read from the physics frame.
|
||||||
|
float localX = (float)((position.EastWest * 240d) + 84d - (((double)blockX - 127d) * 192d));
|
||||||
|
float localY = (float)((position.NorthSouth * 240d) + 84d - (((double)blockY - 127d) * 192d));
|
||||||
|
float localZ = (float)(position.Elevation * 240d);
|
||||||
|
|
||||||
|
return new MmCoordinates(LandblockToEW(cell, localX), LandblockToNS(cell, localY), localZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The original's east-west formula: a float, from the cell's landblock column.</summary>
|
||||||
|
public static float LandblockToEW(uint landcell, float xOff)
|
||||||
|
{
|
||||||
|
uint l = (landcell & 0xFF000000) / 0x200000;
|
||||||
|
return (float)(((xOff / 24) + l - 1019.5) / 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The original's north-south formula: a float, from the cell's landblock row.</summary>
|
||||||
|
public static float LandblockToNS(uint landcell, float yOff)
|
||||||
|
{
|
||||||
|
uint l = (landcell & 0x00FF0000) / 0x2000;
|
||||||
|
return (float)(((yOff / 24) + l - 1019.5) / 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Flat distance in metres between two map coordinates, as the original measured it.</summary>
|
||||||
|
public double DistanceToFlat(MmCoordinates other)
|
||||||
|
{
|
||||||
|
double ns = (((NS * 10) + 1019.5) * 24) - (((other.NS * 10) + 1019.5) * 24);
|
||||||
|
double ew = (((EW * 10) + 1019.5) * 24) - (((other.EW * 10) + 1019.5) * 24);
|
||||||
|
return Math.Sqrt((ns * ns) + (ew * ew));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{Math.Abs(NS):F2}{(NS >= 0 ? 'N' : 'S')} {Math.Abs(EW):F2}{(EW >= 0 ? 'E' : 'W')}");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The client process's own numbers for the telemetry frame: working set in
|
||||||
|
/// bytes, open handles, and CPU use in percent of all cores.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The original measured CPU by sleeping its sending thread for half a second
|
||||||
|
/// on every frame. This measures the same quantity, process CPU time over wall
|
||||||
|
/// time across all cores, over the interval since the previous sample, which
|
||||||
|
/// never blocks.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class ProcessTelemetry
|
||||||
|
{
|
||||||
|
private readonly Process _process = Process.GetCurrentProcess();
|
||||||
|
private TimeSpan _lastCpu;
|
||||||
|
private DateTime _lastWallUtc;
|
||||||
|
|
||||||
|
public ProcessTelemetry()
|
||||||
|
{
|
||||||
|
_lastCpu = _process.TotalProcessorTime;
|
||||||
|
_lastWallUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Working-set memory in bytes (the original's <c>mem_mb</c>, despite the name).</summary>
|
||||||
|
public long MemoryBytes
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
_process.Refresh();
|
||||||
|
return _process.WorkingSet64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int HandleCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
_process.Refresh();
|
||||||
|
return _process.HandleCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>CPU use since the previous call, in percent of all cores.</summary>
|
||||||
|
public float SampleCpuUsage()
|
||||||
|
{
|
||||||
|
_process.Refresh();
|
||||||
|
TimeSpan cpu = _process.TotalProcessorTime;
|
||||||
|
DateTime now = DateTime.UtcNow;
|
||||||
|
double cpuMs = (cpu - _lastCpu).TotalMilliseconds;
|
||||||
|
double elapsedMs = (now - _lastWallUtc).TotalMilliseconds * Environment.ProcessorCount;
|
||||||
|
_lastCpu = cpu;
|
||||||
|
_lastWallUtc = now;
|
||||||
|
return elapsedMs <= 0 ? 0f : (float)(cpuMs / elapsedMs * 100.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,9 @@ namespace OpenAC.MosswartMassacre.Infrastructure;
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class TickScheduler : IDisposable
|
internal sealed class TickScheduler : IDisposable
|
||||||
{
|
{
|
||||||
|
// Frame times summed in doubles land a hair short of a round due time.
|
||||||
|
private const double DueTolerance = 1e-6;
|
||||||
|
|
||||||
private readonly IEvents _events;
|
private readonly IEvents _events;
|
||||||
private readonly IPluginLogger _log;
|
private readonly IPluginLogger _log;
|
||||||
private readonly Action<double> _handler;
|
private readonly Action<double> _handler;
|
||||||
|
|
@ -58,7 +61,7 @@ internal sealed class TickScheduler : IDisposable
|
||||||
_due.Clear();
|
_due.Clear();
|
||||||
foreach (Entry entry in _entries)
|
foreach (Entry entry in _entries)
|
||||||
{
|
{
|
||||||
if (!entry.Cancelled && entry.DueAt <= ElapsedSeconds)
|
if (!entry.Cancelled && entry.DueAt <= ElapsedSeconds + DueTolerance)
|
||||||
_due.Add(entry);
|
_due.Add(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,9 +70,18 @@ internal sealed class TickScheduler : IDisposable
|
||||||
if (entry.Cancelled)
|
if (entry.Cancelled)
|
||||||
continue;
|
continue;
|
||||||
if (entry.Repeat)
|
if (entry.Repeat)
|
||||||
|
{
|
||||||
|
// Re-arm from the due time, not from now, so a fixed cadence
|
||||||
|
// does not drift late by a frame each period; a job that fell
|
||||||
|
// more than a period behind runs once and resumes from now.
|
||||||
|
entry.DueAt += entry.IntervalSeconds;
|
||||||
|
if (entry.DueAt <= ElapsedSeconds)
|
||||||
entry.DueAt = ElapsedSeconds + entry.IntervalSeconds;
|
entry.DueAt = ElapsedSeconds + entry.IntervalSeconds;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
|
{
|
||||||
entry.Cancelled = true;
|
entry.Cancelled = true;
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
entry.Callback();
|
entry.Callback();
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,15 @@ internal sealed class MmContext
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ConnectionSessionId { get; set; } = string.Empty;
|
public string ConnectionSessionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The prismatic taper count for telemetry; the inventory feature supplies it.</summary>
|
||||||
|
public Func<int> PrismaticTaperCount { get; set; } = static () => 0;
|
||||||
|
|
||||||
|
/// <summary>The bot's current meta state name for telemetry, or empty when there is none.</summary>
|
||||||
|
public Func<string> MetaState { get; set; } = static () => string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The character's vitae penalty in percent (0 when none), as the original's vitals frame carried it.</summary>
|
||||||
|
public Func<int> Vitae { get; set; } = static () => 0;
|
||||||
|
|
||||||
/// <summary>The per-process id the original put in every telemetry frame.</summary>
|
/// <summary>The per-process id the original put in every telemetry frame.</summary>
|
||||||
public static string ProcessSessionId { get; } = Guid.NewGuid().ToString("N");
|
public static string ProcessSessionId { get; } = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
|
|
||||||
36
src/OpenAC.MosswartMassacre/Streams/BotControl.cs
Normal file
36
src/OpenAC.MosswartMassacre/Streams/BotControl.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
namespace OpenAC.MosswartMassacre.Streams;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The original's commands that drove the bot. They go through the bot's own
|
||||||
|
/// <c>/vt</c> verbs, so they work with whichever bot plugin answers to them.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class BotControl : IMmFeature
|
||||||
|
{
|
||||||
|
private readonly MmContext _context;
|
||||||
|
|
||||||
|
public BotControl(MmContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
context.Commands.Register("getmetastate", _ => _context.Chat.Write(_context.MetaState() ?? string.Empty), "Show current VTank meta state");
|
||||||
|
context.Commands.Register("nextwp", _ => Submit("/vt nextwp"), "Advance VTank to next waypoint");
|
||||||
|
context.Commands.Register("prevwp", _ => Submit("/vt prevwp"), "Move VTank back to previous waypoint");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnLogin()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnLogoff()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Submit(string command)
|
||||||
|
{
|
||||||
|
if (!_context.Automation.Chat.Submit(command))
|
||||||
|
_context.Chat.Write("Failed to reach VTank. Is it running?");
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/OpenAC.MosswartMassacre/Streams/ChatLineText.cs
Normal file
47
src/OpenAC.MosswartMassacre/Streams/ChatLineText.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Streams;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A chat line worded the way the chat window shows it, which is the text the
|
||||||
|
/// original plugin received for every line and parsed and forwarded.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ChatLineText
|
||||||
|
{
|
||||||
|
// The host's line kinds, in the order the host numbers them.
|
||||||
|
private const int LocalSpeech = 0;
|
||||||
|
private const int RangedSpeech = 1;
|
||||||
|
private const int Channel = 2;
|
||||||
|
private const int Tell = 3;
|
||||||
|
private const int Popup = 5;
|
||||||
|
private const int Emote = 6;
|
||||||
|
private const int SoulEmote = 7;
|
||||||
|
|
||||||
|
public static string Of(in PluginChatMessage message)
|
||||||
|
{
|
||||||
|
string text = message.Text ?? string.Empty;
|
||||||
|
string sender = message.Sender ?? string.Empty;
|
||||||
|
bool own = string.IsNullOrEmpty(sender) || sender == "You";
|
||||||
|
return message.Kind switch
|
||||||
|
{
|
||||||
|
LocalSpeech => own ? $"You say, \"{text}\"" : $"{sender} says, \"{text}\"",
|
||||||
|
RangedSpeech => own ? $"You shout, \"{text}\"" : $"{sender} shouts, \"{text}\"",
|
||||||
|
Channel => ChannelLine(message.ChannelName ?? string.Empty, sender, own, text),
|
||||||
|
Tell => message.SenderObjectId != 0
|
||||||
|
? $"{sender} tells you, \"{text}\""
|
||||||
|
: $"You tell {sender}, \"{text}\"",
|
||||||
|
Popup => $"[Popup] {text}",
|
||||||
|
Emote or SoulEmote => $"* {sender} {text}",
|
||||||
|
_ => text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ChannelLine(string channel, string sender, bool own, string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(channel))
|
||||||
|
return own ? $"You say, \"{text}\"" : $"{sender} says, \"{text}\"";
|
||||||
|
return own
|
||||||
|
? $"[{channel}] You say, \"{text}\""
|
||||||
|
: $"[{channel}] {sender} says, \"{text}\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/OpenAC.MosswartMassacre/Streams/ChatRouter.cs
Normal file
106
src/OpenAC.MosswartMassacre/Streams/ChatRouter.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using OpenAC.MosswartMassacre.Backend;
|
||||||
|
using OpenAC.MosswartMassacre.Trackers;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Streams;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every chat line, in the original's order: first the kill counter, the combat
|
||||||
|
/// tracker, the rare tracker and the allegiance <c>!report</c> reply read it;
|
||||||
|
/// then the line goes to the backend as a <c>chat</c> frame.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ChatRouter : IMmFeature
|
||||||
|
{
|
||||||
|
/// <summary>The retail text class of allegiance chat, as the original compared it.</summary>
|
||||||
|
public const int AllegianceColor = 18;
|
||||||
|
|
||||||
|
private readonly MmContext _context;
|
||||||
|
private readonly KillTracker _kills;
|
||||||
|
private readonly CombatStatsTracker _combat;
|
||||||
|
private readonly RareTracker _rares;
|
||||||
|
private readonly Action<PluginChatMessage> _onReceived;
|
||||||
|
private bool _subscribed;
|
||||||
|
|
||||||
|
public ChatRouter(MmContext context, KillTracker kills, CombatStatsTracker combat, RareTracker rares)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_kills = kills;
|
||||||
|
_combat = combat;
|
||||||
|
_rares = rares;
|
||||||
|
_onReceived = OnReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnLogin()
|
||||||
|
{
|
||||||
|
if (_subscribed)
|
||||||
|
return;
|
||||||
|
_context.Automation.Chat.Received += _onReceived;
|
||||||
|
_subscribed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnLogoff()
|
||||||
|
{
|
||||||
|
if (!_subscribed)
|
||||||
|
return;
|
||||||
|
_context.Automation.Chat.Received -= _onReceived;
|
||||||
|
_subscribed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => OnLogoff();
|
||||||
|
|
||||||
|
internal void OnReceived(PluginChatMessage message)
|
||||||
|
{
|
||||||
|
// Status notices float over the world and never reach the chat window.
|
||||||
|
if (message.Kind == PluginChatMessage.StatusTextKind)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string text = ChatLineText.Of(message);
|
||||||
|
int color = message.LogTextType;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_kills.CheckForKill(text);
|
||||||
|
_combat.ProcessChatLine(text);
|
||||||
|
if (_rares.CheckForRare(text, out _))
|
||||||
|
_kills.RareCount = _rares.RareCount;
|
||||||
|
|
||||||
|
if (color == AllegianceColor && text.EndsWith("!report\"", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
TimeSpan elapsed = _context.Clock.Now - _kills.StatsStartTime;
|
||||||
|
string report = string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
"Total Kills: {0}, Kills per Hour: {1:F2}, Elapsed Time: {2:dd\\.hh\\:mm\\:ss}, Rares Found: {3}",
|
||||||
|
_kills.TotalKills, _kills.KillsPerHour, elapsed, _rares.RareCount);
|
||||||
|
_context.Host.Log.Info($"[Mosswart Massacre] Reporting to allegiance: {report}");
|
||||||
|
_context.Automation.Chat.Submit($"/a {report}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_context.Chat.Write("Error processing chat message: " + ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
SendChat(color, Normalize(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SendChat(int color, string text) => _context.Send(new
|
||||||
|
{
|
||||||
|
type = "chat",
|
||||||
|
timestamp = Wire.Timestamp(_context.Clock),
|
||||||
|
character_name = _context.CharacterName,
|
||||||
|
text,
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>The original's clean-up: tags removed, trailing line breaks trimmed, runs of spaces collapsed.</summary>
|
||||||
|
internal static string Normalize(string raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(raw))
|
||||||
|
return raw;
|
||||||
|
string noTags = Regex.Replace(raw, "<[^>]+>", "");
|
||||||
|
string trimmed = noTags.TrimEnd('\r', '\n');
|
||||||
|
return Regex.Replace(trimmed, @"[ ]{2,}", " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/OpenAC.MosswartMassacre/Streams/SessionStats.cs
Normal file
129
src/OpenAC.MosswartMassacre/Streams/SessionStats.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using OpenAC.MosswartMassacre.Infrastructure;
|
||||||
|
using OpenAC.MosswartMassacre.Trackers;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Streams;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The session's counters (kills, deaths, rares) and the commands that show or
|
||||||
|
/// reset them. The original loaded with each character, so a login starts a
|
||||||
|
/// fresh session.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SessionStats : IMmFeature
|
||||||
|
{
|
||||||
|
/// <summary>The player's number-of-deaths property.</summary>
|
||||||
|
public const uint NumDeathsProperty = 43;
|
||||||
|
|
||||||
|
private readonly MmContext _context;
|
||||||
|
private readonly Action<string> _onDied;
|
||||||
|
private IDisposable? _statsTimer;
|
||||||
|
|
||||||
|
public SessionStats(MmContext context, KillTracker kills, RareTracker rares)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
Kills = kills;
|
||||||
|
Rares = rares;
|
||||||
|
_onDied = _ => OnDeath();
|
||||||
|
|
||||||
|
context.Commands.Register("report", _ => Report(), "Show kill/death/rare stats");
|
||||||
|
context.Commands.Register("loc", _ => Location(), "Show current location");
|
||||||
|
context.Commands.Register("reset", _ => Reset(), "Reset kill/rare counters");
|
||||||
|
context.Commands.Register("deathstats", _ => DeathStats(), "Show death tracking stats");
|
||||||
|
context.Commands.Register("verbose", _ => ToggleVerbose(), "Toggle verbose debug logging");
|
||||||
|
}
|
||||||
|
|
||||||
|
public KillTracker Kills { get; }
|
||||||
|
|
||||||
|
public RareTracker Rares { get; }
|
||||||
|
|
||||||
|
public void OnLogin()
|
||||||
|
{
|
||||||
|
Kills.RestartStats();
|
||||||
|
Rares.RareCount = 0;
|
||||||
|
Kills.SetTotalDeaths(ReadNumDeaths());
|
||||||
|
_statsTimer ??= _context.Scheduler.Every(TimeSpan.FromSeconds(1), Kills.Update);
|
||||||
|
_context.Host.Events.LocalPlayerDied += _onDied;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnLogoff()
|
||||||
|
{
|
||||||
|
_context.Host.Events.LocalPlayerDied -= _onDied;
|
||||||
|
_statsTimer?.Dispose();
|
||||||
|
_statsTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => OnLogoff();
|
||||||
|
|
||||||
|
internal void OnDeath()
|
||||||
|
{
|
||||||
|
Kills.OnDeath();
|
||||||
|
Kills.SetTotalDeaths(ReadNumDeaths());
|
||||||
|
}
|
||||||
|
|
||||||
|
private int ReadNumDeaths()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _context.Automation.Objects.TryGetIntProperty(
|
||||||
|
_context.Automation.Character.ObjectId, NumDeathsProperty, out int deaths)
|
||||||
|
? deaths
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Report()
|
||||||
|
{
|
||||||
|
TimeSpan elapsed = _context.Clock.Now - Kills.StatsStartTime;
|
||||||
|
_context.Chat.Write(string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
"Total Kills: {0}, Kills per Hour: {1:F2}, Elapsed Time: {2:dd\\.hh\\:mm\\:ss}, Rares Found: {3}, Session Deaths: {4}, Total Deaths: {5}",
|
||||||
|
Kills.TotalKills, Kills.KillsPerHour, elapsed, Rares.RareCount, Kills.SessionDeaths, Kills.TotalDeaths));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Location()
|
||||||
|
{
|
||||||
|
var position = _context.Automation.Navigation.Snapshot.Position;
|
||||||
|
MmCoordinates here = MmCoordinates.From(position);
|
||||||
|
uint blockX = (position.CellId >> 24) & 0xFFu;
|
||||||
|
uint blockY = (position.CellId >> 16) & 0xFFu;
|
||||||
|
double x = (position.EastWest * 240d) + 84d - (((double)blockX - 127d) * 192d);
|
||||||
|
double y = (position.NorthSouth * 240d) + 84d - (((double)blockY - 127d) * 192d);
|
||||||
|
_context.Chat.Write(string.Create(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
$"Location: {here} (X={x:F1}, Y={y:F1}, Z={here.Z:F1})"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reset()
|
||||||
|
{
|
||||||
|
Kills.RestartStats();
|
||||||
|
Rares.RareCount = 0;
|
||||||
|
_context.Chat.Write($"Stats have been reset. Session deaths: {Kills.SessionDeaths}, Total deaths: {Kills.TotalDeaths}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeathStats()
|
||||||
|
{
|
||||||
|
_context.Chat.Write("=== Death Tracking Statistics ===");
|
||||||
|
_context.Chat.Write($"Session Deaths: {Kills.SessionDeaths}");
|
||||||
|
_context.Chat.Write($"Total Deaths: {Kills.TotalDeaths}");
|
||||||
|
int current = ReadNumDeaths();
|
||||||
|
_context.Chat.Write($"Character Property NumDeaths: {current}");
|
||||||
|
if (current != Kills.TotalDeaths)
|
||||||
|
{
|
||||||
|
_context.Chat.Write("[WARNING] Death count sync issue detected!");
|
||||||
|
_context.Chat.Write($"Updating totalDeaths from {Kills.TotalDeaths} to {current}");
|
||||||
|
Kills.SetTotalDeaths(current);
|
||||||
|
}
|
||||||
|
_context.Chat.Write("Death tracking is active and will increment on character death.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleVerbose()
|
||||||
|
{
|
||||||
|
_context.Settings.Current.VerboseLogging = !_context.Settings.Current.VerboseLogging;
|
||||||
|
_context.Settings.Save();
|
||||||
|
_context.Chat.Write($"Verbose logging: {(_context.Settings.Current.VerboseLogging ? "ENABLED" : "DISABLED")}");
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/OpenAC.MosswartMassacre/Streams/SpawnPortalStream.cs
Normal file
110
src/OpenAC.MosswartMassacre/Streams/SpawnPortalStream.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using OpenAC.MosswartMassacre.Backend;
|
||||||
|
using OpenAC.MosswartMassacre.Infrastructure;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Streams;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>spawn</c> frame for every monster that appears and the
|
||||||
|
/// <c>portal</c> frame for every portal that appears within 240 metres, with
|
||||||
|
/// the coordinates as strings the way the original formatted them.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SpawnPortalStream : IMmFeature
|
||||||
|
{
|
||||||
|
public const double PortalRangeMeters = 240.0;
|
||||||
|
|
||||||
|
private readonly MmContext _context;
|
||||||
|
private readonly Action<PluginObjectChange> _onChanged;
|
||||||
|
private bool _subscribed;
|
||||||
|
|
||||||
|
public SpawnPortalStream(MmContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_onChanged = OnObjectChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnLogin()
|
||||||
|
{
|
||||||
|
if (_subscribed)
|
||||||
|
return;
|
||||||
|
_context.Host.Events.ObjectChanged += _onChanged;
|
||||||
|
_subscribed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnLogoff()
|
||||||
|
{
|
||||||
|
if (!_subscribed)
|
||||||
|
return;
|
||||||
|
_context.Host.Events.ObjectChanged -= _onChanged;
|
||||||
|
_subscribed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => OnLogoff();
|
||||||
|
|
||||||
|
internal void OnObjectChanged(PluginObjectChange change)
|
||||||
|
{
|
||||||
|
if (change.Kind != PluginObjectChangeKind.Created || change.Current is not { } obj)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (obj.ObjectClass == PluginObjectClass.Monster)
|
||||||
|
OnSpawn(obj);
|
||||||
|
else if (obj.ObjectClass == PluginObjectClass.Portal)
|
||||||
|
OnPortal(obj);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_context.Chat.Write($"[WS] Spawn send failed: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSpawn(PluginWorldObject mob)
|
||||||
|
{
|
||||||
|
if (!mob.HasPosition)
|
||||||
|
return;
|
||||||
|
(string ns, string ew, string z) = Format(mob.Position);
|
||||||
|
_context.Send(new
|
||||||
|
{
|
||||||
|
type = "spawn",
|
||||||
|
timestamp = Wire.Timestamp(_context.Clock),
|
||||||
|
character_name = _context.CharacterName,
|
||||||
|
mob = mob.Name,
|
||||||
|
ns,
|
||||||
|
ew,
|
||||||
|
z,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPortal(PluginWorldObject portal)
|
||||||
|
{
|
||||||
|
if (!portal.HasPosition)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var portalPos = new MmCoordinates(portal.Position.EastWest, portal.Position.NorthSouth, 0);
|
||||||
|
MmCoordinates player = MmCoordinates.From(_context.Automation.Navigation.Snapshot.Position);
|
||||||
|
if (player.DistanceToFlat(portalPos) > PortalRangeMeters)
|
||||||
|
return;
|
||||||
|
|
||||||
|
(string ns, string ew, string z) = Format(portal.Position);
|
||||||
|
_context.Send(new
|
||||||
|
{
|
||||||
|
type = "portal",
|
||||||
|
timestamp = Wire.Timestamp(_context.Clock),
|
||||||
|
character_name = _context.CharacterName,
|
||||||
|
portal_name = portal.Name,
|
||||||
|
ns,
|
||||||
|
ew,
|
||||||
|
z,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Ns, string Ew, string Z) Format(in PluginNavigationPosition position)
|
||||||
|
{
|
||||||
|
const string fmt = "F7";
|
||||||
|
string ns = position.NorthSouth.ToString(fmt, CultureInfo.InvariantCulture);
|
||||||
|
string ew = position.EastWest.ToString(fmt, CultureInfo.InvariantCulture);
|
||||||
|
string z = ((float)(position.Elevation * 240d)).ToString("F2", CultureInfo.InvariantCulture);
|
||||||
|
return (ns, ew, z);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs
Normal file
84
src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using OpenAC.MosswartMassacre.Backend;
|
||||||
|
using OpenAC.MosswartMassacre.Infrastructure;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Streams;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>telemetry</c> frame: sent right after the socket registers and then
|
||||||
|
/// every five seconds, as the original's socket loop did.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Several fields are strings on the wire (kill rate, deaths, taper count) and
|
||||||
|
/// <c>mem_mb</c> carries bytes; the backend reads them that way.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class TelemetryStream : IMmFeature, IBackendConnectionListener
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan Interval = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
private readonly MmContext _context;
|
||||||
|
private readonly SessionStats _stats;
|
||||||
|
private readonly ProcessTelemetry _process = new();
|
||||||
|
private IDisposable? _timer;
|
||||||
|
|
||||||
|
public TelemetryStream(MmContext context, SessionStats stats)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_stats = stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnBackendConnected()
|
||||||
|
{
|
||||||
|
if (!_context.IsLoggedIn)
|
||||||
|
return;
|
||||||
|
SendNow();
|
||||||
|
_timer?.Dispose();
|
||||||
|
_timer = _context.Scheduler.Every(Interval, SendNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnLogin()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnLogoff()
|
||||||
|
{
|
||||||
|
_timer?.Dispose();
|
||||||
|
_timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => OnLogoff();
|
||||||
|
|
||||||
|
internal void SendNow()
|
||||||
|
{
|
||||||
|
if (!_context.IsLoggedIn)
|
||||||
|
return;
|
||||||
|
_context.Send(Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object Build()
|
||||||
|
{
|
||||||
|
MmCoordinates coords = MmCoordinates.From(_context.Automation.Navigation.Snapshot.Position);
|
||||||
|
var kills = _stats.Kills;
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
type = "telemetry",
|
||||||
|
character_name = _context.CharacterName,
|
||||||
|
char_tag = _context.Settings.Current.CharTag ?? "",
|
||||||
|
session_id = MmContext.ProcessSessionId,
|
||||||
|
timestamp = Wire.Timestamp(_context.Clock),
|
||||||
|
ew = coords.EW,
|
||||||
|
ns = coords.NS,
|
||||||
|
z = coords.Z,
|
||||||
|
kills = kills.TotalKills,
|
||||||
|
kills_per_hour = kills.KillsPerHour.ToString("F0", CultureInfo.CurrentCulture),
|
||||||
|
onlinetime = (_context.Clock.Now - kills.StatsStartTime).ToString(@"dd\.hh\:mm\:ss", CultureInfo.InvariantCulture),
|
||||||
|
deaths = kills.SessionDeaths.ToString(CultureInfo.CurrentCulture),
|
||||||
|
total_deaths = kills.TotalDeaths.ToString(CultureInfo.CurrentCulture),
|
||||||
|
prismatic_taper_count = _context.PrismaticTaperCount().ToString(CultureInfo.CurrentCulture),
|
||||||
|
vt_state = _context.MetaState() ?? string.Empty,
|
||||||
|
mem_mb = _process.MemoryBytes,
|
||||||
|
cpu_pct = _process.SampleCpuUsage(),
|
||||||
|
mem_handles = _process.HandleCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/OpenAC.MosswartMassacre/Streams/VitalsStream.cs
Normal file
55
src/OpenAC.MosswartMassacre/Streams/VitalsStream.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
using OpenAC.MosswartMassacre.Backend;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Streams;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>vitals</c> frame every five seconds while streaming is on. The
|
||||||
|
/// backend watches <c>vitae</c> going from 0 to above 0 to notice a death.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class VitalsStream(MmContext context) : IMmFeature
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan Interval = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
private IDisposable? _timer;
|
||||||
|
|
||||||
|
public void OnLogin() => _timer ??= context.Scheduler.Every(Interval, SendNow);
|
||||||
|
|
||||||
|
public void OnLogoff()
|
||||||
|
{
|
||||||
|
_timer?.Dispose();
|
||||||
|
_timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => OnLogoff();
|
||||||
|
|
||||||
|
internal void SendNow()
|
||||||
|
{
|
||||||
|
if (!context.Settings.Current.WebSocketEnabled || !context.IsLoggedIn)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var character = context.Automation.Character;
|
||||||
|
int currentHealth = (int)character.CurrentHealth;
|
||||||
|
int currentStamina = (int)character.CurrentStamina;
|
||||||
|
int currentMana = (int)character.CurrentMana;
|
||||||
|
int maxHealth = (int)character.MaxHealth;
|
||||||
|
int maxStamina = (int)character.MaxStamina;
|
||||||
|
int maxMana = (int)character.MaxMana;
|
||||||
|
|
||||||
|
context.Send(new
|
||||||
|
{
|
||||||
|
type = "vitals",
|
||||||
|
timestamp = Wire.Timestamp(context.Clock),
|
||||||
|
character_name = context.CharacterName,
|
||||||
|
health_current = currentHealth,
|
||||||
|
health_max = maxHealth,
|
||||||
|
health_percentage = maxHealth > 0 ? Math.Round((double)currentHealth / maxHealth * 100, 1) : 0,
|
||||||
|
stamina_current = currentStamina,
|
||||||
|
stamina_max = maxStamina,
|
||||||
|
stamina_percentage = maxStamina > 0 ? Math.Round((double)currentStamina / maxStamina * 100, 1) : 0,
|
||||||
|
mana_current = currentMana,
|
||||||
|
mana_max = maxMana,
|
||||||
|
mana_percentage = maxMana > 0 ? Math.Round((double)currentMana / maxMana * 100, 1) : 0,
|
||||||
|
vitae = context.Vitae(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
329
tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs
Normal file
329
tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using OpenAC.MosswartMassacre.Streams;
|
||||||
|
using OpenAC.MosswartMassacre.Trackers;
|
||||||
|
|
||||||
|
namespace OpenAC.MosswartMassacre.Tests.Streams;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The exact frames the original plugin sent, one per type in this slice. Each
|
||||||
|
/// expected string is derived by hand from the original source (file and lines
|
||||||
|
/// cited per test); field order, JSON types and number formats are the
|
||||||
|
/// contract with the backend.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StreamGoldenTests
|
||||||
|
{
|
||||||
|
// Clock of the harness: UTC 2026-09-25 08:30:15.123, local 10:30:15.123.
|
||||||
|
private const string Stamp = "2026-09-25T08:30:15.1230000Z";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A position in landblock 0xA9B4 whose local floats are exact binary
|
||||||
|
/// fractions, so the original's float arithmetic is easy to follow:
|
||||||
|
/// x = 96 -> EW = ((96/24) + 169*8 - 1019.5) / 10 = 33.65 as a float;
|
||||||
|
/// y = 120 -> NS = ((120/24) + 180*8 - 1019.5) / 10 = 42.55 as a float;
|
||||||
|
/// z = 42.5.
|
||||||
|
/// </summary>
|
||||||
|
internal static PluginNavigationPosition Position(float x = 96f, float y = 120f, float z = 42.5f, uint cell = 0xA9B40019u)
|
||||||
|
{
|
||||||
|
uint bx = (cell >> 24) & 0xFFu;
|
||||||
|
uint by = (cell >> 16) & 0xFFu;
|
||||||
|
return new PluginNavigationPosition(
|
||||||
|
cell,
|
||||||
|
(((double)bx - 127d) * 192d + x - 84d) / 240d,
|
||||||
|
(((double)by - 127d) * 192d + y - 84d) / 240d,
|
||||||
|
z / 240d,
|
||||||
|
90f,
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string D(float value) => ((double)value).ToString("R", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static PluginHarness LoggedIn(Action<PluginHarness>? arrange = null)
|
||||||
|
{
|
||||||
|
var harness = new PluginHarness("Mossy");
|
||||||
|
harness.Host.Automation.Navigation.Snapshot = new PluginNavigationSnapshot(true, false, 1u, Position(), false, false);
|
||||||
|
arrange?.Invoke(harness);
|
||||||
|
harness.Login();
|
||||||
|
harness.Frames.Clear();
|
||||||
|
return harness;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Coordinates_follow_the_originals_float_arithmetic()
|
||||||
|
{
|
||||||
|
var coords = OpenAC.MosswartMassacre.Infrastructure.MmCoordinates.From(Position());
|
||||||
|
|
||||||
|
Assert.Equal((double)33.65f, coords.EW);
|
||||||
|
Assert.Equal((double)42.55f, coords.NS);
|
||||||
|
Assert.Equal(42.5, coords.Z);
|
||||||
|
// A float widened to double, not the double 33.65.
|
||||||
|
Assert.NotEqual(33.65, coords.EW);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Coordinates_recover_the_float_local_position_for_awkward_values()
|
||||||
|
{
|
||||||
|
// 97.123f is not an exact binary fraction; the original computed from
|
||||||
|
// exactly this float, and so must the port.
|
||||||
|
var coords = OpenAC.MosswartMassacre.Infrastructure.MmCoordinates.From(Position(x: 97.123f, y: 3.3f));
|
||||||
|
|
||||||
|
Assert.Equal((double)OpenAC.MosswartMassacre.Infrastructure.MmCoordinates.LandblockToEW(0xA9B40019u, 97.123f), coords.EW);
|
||||||
|
Assert.Equal((double)OpenAC.MosswartMassacre.Infrastructure.MmCoordinates.LandblockToNS(0xA9B40019u, 3.3f), coords.NS);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vitals_frame()
|
||||||
|
{
|
||||||
|
// PluginCore.cs:1062-1106 (SendVitalsUpdate): anonymous type in this
|
||||||
|
// order; percentages Math.Round(.., 1) as double (0 -> 0.0 by the
|
||||||
|
// ternary's double type); vitae an int.
|
||||||
|
using var harness = LoggedIn(h =>
|
||||||
|
{
|
||||||
|
var c = h.Host.Automation.Character;
|
||||||
|
c.CurrentHealth = 150; c.MaxHealth = 200;
|
||||||
|
c.CurrentStamina = 0; c.MaxStamina = 0;
|
||||||
|
c.CurrentMana = 333; c.MaxMana = 999;
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.Feature<VitalsStream>().SendNow();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"{\"type\":\"vitals\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
|
||||||
|
+ "\"health_current\":150,\"health_max\":200,\"health_percentage\":75.0,"
|
||||||
|
+ "\"stamina_current\":0,\"stamina_max\":0,\"stamina_percentage\":0.0,"
|
||||||
|
+ "\"mana_current\":333,\"mana_max\":999,\"mana_percentage\":33.3,"
|
||||||
|
+ "\"vitae\":0}",
|
||||||
|
Assert.Single(harness.Frames));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vitals_are_sent_every_five_seconds()
|
||||||
|
{
|
||||||
|
using var harness = LoggedIn();
|
||||||
|
|
||||||
|
harness.Run(10.05);
|
||||||
|
|
||||||
|
Assert.Equal(2, harness.FramesOfType("vitals").Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Telemetry_frame()
|
||||||
|
{
|
||||||
|
// WebSocket.cs:476-507 (BuildPayloadJson): ew/ns/z doubles from
|
||||||
|
// Coordinates.Me (float values); kills an int; kills_per_hour F0
|
||||||
|
// string; onlinetime dd.hh:mm:ss; deaths/total_deaths/taper strings;
|
||||||
|
// vt_state string; mem_mb a long (bytes); cpu_pct a float;
|
||||||
|
// mem_handles an int.
|
||||||
|
using var harness = LoggedIn(h =>
|
||||||
|
h.Host.Automation.Objects.Properties[0x50000001] = new PluginItemProperties(
|
||||||
|
new Dictionary<uint, int> { [43] = 7 },
|
||||||
|
new Dictionary<uint, long>(),
|
||||||
|
new Dictionary<uint, bool>(),
|
||||||
|
new Dictionary<uint, double>(),
|
||||||
|
new Dictionary<uint, string>(),
|
||||||
|
new Dictionary<uint, uint>(),
|
||||||
|
new Dictionary<uint, uint>()));
|
||||||
|
harness.Context.PrismaticTaperCount = () => 1234;
|
||||||
|
harness.Context.MetaState = () => "Combat";
|
||||||
|
harness.Context.Settings.Current.CharTag = "tank";
|
||||||
|
harness.Clock.Advance(TimeSpan.FromMinutes(90));
|
||||||
|
|
||||||
|
string json = Newtonsoft.Json.JsonConvert.SerializeObject(harness.Feature<TelemetryStream>().Build());
|
||||||
|
var frame = Newtonsoft.Json.Linq.JObject.Parse(json);
|
||||||
|
|
||||||
|
string[] expectedOrder =
|
||||||
|
[
|
||||||
|
"type", "character_name", "char_tag", "session_id", "timestamp", "ew", "ns", "z", "kills",
|
||||||
|
"kills_per_hour", "onlinetime", "deaths", "total_deaths", "prismatic_taper_count", "vt_state",
|
||||||
|
"mem_mb", "cpu_pct", "mem_handles",
|
||||||
|
];
|
||||||
|
Assert.Equal(expectedOrder, frame.Properties().Select(p => p.Name));
|
||||||
|
|
||||||
|
string head = "{\"type\":\"telemetry\",\"character_name\":\"Mossy\",\"char_tag\":\"tank\",\"session_id\":\""
|
||||||
|
+ MmContext.ProcessSessionId + "\",\"timestamp\":\"2026-09-25T10:00:15.1230000Z\","
|
||||||
|
+ "\"ew\":" + D(33.65f) + ",\"ns\":" + D(42.55f) + ",\"z\":42.5,\"kills\":0,"
|
||||||
|
+ "\"kills_per_hour\":\"0\",\"onlinetime\":\"00.01:30:00\",\"deaths\":\"0\",\"total_deaths\":\"7\","
|
||||||
|
+ "\"prismatic_taper_count\":\"1234\",\"vt_state\":\"Combat\",\"mem_mb\":";
|
||||||
|
Assert.StartsWith(head, json);
|
||||||
|
Assert.Equal(Newtonsoft.Json.Linq.JTokenType.Integer, frame["mem_mb"]!.Type);
|
||||||
|
Assert.Equal(Newtonsoft.Json.Linq.JTokenType.Float, frame["cpu_pct"]!.Type);
|
||||||
|
Assert.Equal(Newtonsoft.Json.Linq.JTokenType.Integer, frame["mem_handles"]!.Type);
|
||||||
|
Assert.Equal(32, ((string)frame["session_id"]!).Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Telemetry_goes_out_on_connect_and_then_every_five_seconds()
|
||||||
|
{
|
||||||
|
using var harness = LoggedIn();
|
||||||
|
|
||||||
|
harness.SimulateConnected();
|
||||||
|
Assert.Single(harness.FramesOfType("telemetry"));
|
||||||
|
|
||||||
|
harness.Run(5.05);
|
||||||
|
Assert.Equal(2, harness.FramesOfType("telemetry").Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Chat_frame_normalises_the_line()
|
||||||
|
{
|
||||||
|
// ChatEventRouter.cs:68-91 (AllChatText + NormalizeChatLine): tags
|
||||||
|
// stripped, trailing CR/LF trimmed, runs of spaces collapsed; color is
|
||||||
|
// the line's text class.
|
||||||
|
using var harness = LoggedIn();
|
||||||
|
|
||||||
|
harness.Host.Automation.Chat.Deliver("Hello there", kind: 0, sender: "Horan", logTextType: 2, senderObjectId: 0x50000002);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"{\"type\":\"chat\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
|
||||||
|
+ "\"text\":\"Horan says, \\\"Hello there\\\"\",\"color\":2}",
|
||||||
|
Assert.Single(harness.Frames));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Spawn_frame_has_string_coordinates()
|
||||||
|
{
|
||||||
|
// PluginCore.cs:838-895 + WebSocket.cs:290-304: ns/ew F7 invariant
|
||||||
|
// strings, z F2 string (landblock-local metres).
|
||||||
|
using var harness = LoggedIn();
|
||||||
|
|
||||||
|
harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(0x80001234, PluginObjectChangeKind.Created)
|
||||||
|
{
|
||||||
|
Current = new PluginWorldObject(0x80001234, 1, "Drudge Skulker", PluginObjectClass.Monster, 16, 0, 0)
|
||||||
|
{
|
||||||
|
HasPosition = true,
|
||||||
|
Position = Position(x: 96f, y: 120f, z: 12.25f),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"{\"type\":\"spawn\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
|
||||||
|
+ "\"mob\":\"Drudge Skulker\",\"ns\":\"42.5500000\",\"ew\":\"33.6500000\",\"z\":\"12.25\"}",
|
||||||
|
Assert.Single(harness.Frames));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Portal_frame_only_within_240_metres()
|
||||||
|
{
|
||||||
|
// PluginCore.cs:897-961: flat distance > 240 m -> skipped.
|
||||||
|
using var harness = LoggedIn();
|
||||||
|
PluginWorldObject Portal(uint id, PluginNavigationPosition p) =>
|
||||||
|
new(id, 1, "Portal to Holtburg", PluginObjectClass.Portal, 0x10000, 0, 0) { HasPosition = true, Position = p };
|
||||||
|
|
||||||
|
harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(1, PluginObjectChangeKind.Created)
|
||||||
|
{
|
||||||
|
Current = Portal(1, Position(x: 180f, y: 120f, z: 1f)),
|
||||||
|
});
|
||||||
|
harness.Host.Events.RaiseObjectChanged(new PluginObjectChange(2, PluginObjectChangeKind.Created)
|
||||||
|
{
|
||||||
|
Current = Portal(2, Position(x: 96f, y: 120f, z: 1f, cell: 0xABB40019u)),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"{\"type\":\"portal\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
|
||||||
|
+ "\"portal_name\":\"Portal to Holtburg\",\"ns\":\"42.5500000\",\"ew\":\"34.0000000\",\"z\":\"1.00\"}",
|
||||||
|
Assert.Single(harness.Frames));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rare_frame_has_number_coordinates_and_announces_in_allegiance_later()
|
||||||
|
{
|
||||||
|
// RareTracker.cs:31-47 + WebSocket.cs:321-337: name = the item text,
|
||||||
|
// ew/ns/z numbers from Coordinates.Me; "/a <item>" 3 s later.
|
||||||
|
using var harness = LoggedIn();
|
||||||
|
|
||||||
|
harness.Host.Automation.Chat.Deliver("Mossy has discovered the Pearl of Blood Drinking!", kind: 4, logTextType: 0);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"{\"type\":\"rare\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
|
||||||
|
+ "\"name\":\"Pearl of Blood Drinking\",\"ew\":" + D(33.65f) + ",\"ns\":" + D(42.55f) + ",\"z\":42.5}",
|
||||||
|
harness.Frames[0]);
|
||||||
|
Assert.Equal("chat", (string?)Newtonsoft.Json.Linq.JObject.Parse(harness.Frames[1])["type"]);
|
||||||
|
|
||||||
|
harness.Run(2.9);
|
||||||
|
Assert.Empty(harness.Host.Automation.Chat.Submitted);
|
||||||
|
harness.Run(0.2);
|
||||||
|
Assert.Equal(["/a Pearl of Blood Drinking"], harness.Host.Automation.Chat.Submitted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Another_players_rare_is_not_counted()
|
||||||
|
{
|
||||||
|
using var harness = LoggedIn();
|
||||||
|
|
||||||
|
harness.Host.Automation.Chat.Deliver("Horan has discovered the Pearl of Blood Drinking!", kind: 4);
|
||||||
|
|
||||||
|
Assert.Empty(harness.FramesOfType("rare"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Combat_stats_frame()
|
||||||
|
{
|
||||||
|
// CombatStatsTracker.cs:520-550 + CombatInfo.cs: session is the
|
||||||
|
// CombatSessionState fields (snake_case) then Newtonsoft also writes
|
||||||
|
// each monster's public aggregate getters (PascalCase); enum keys by
|
||||||
|
// name; lifetime null; session_id the per-connection id.
|
||||||
|
using var harness = LoggedIn();
|
||||||
|
harness.SimulateConnected();
|
||||||
|
harness.Frames.Clear();
|
||||||
|
var tracker = harness.Feature<CombatStatsTracker>();
|
||||||
|
|
||||||
|
harness.Host.Automation.Chat.Deliver("You slash Drudge Skulker for 25 points of slashing damage!", kind: 8, logTextType: 6);
|
||||||
|
harness.Frames.Clear();
|
||||||
|
tracker.OnSendTick();
|
||||||
|
|
||||||
|
string sessionStart = tracker.Session.SessionStart;
|
||||||
|
Assert.Equal(
|
||||||
|
"{\"type\":\"combat_stats\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\","
|
||||||
|
+ "\"session_id\":\"Mossy-20260925-083015\","
|
||||||
|
+ "\"session\":{\"total_damage_given\":25,\"total_damage_received\":0,\"total_kills\":0,"
|
||||||
|
+ "\"total_aetheria_surges\":0,\"total_cloak_surges\":0,\"session_start\":\"" + sessionStart + "\","
|
||||||
|
+ "\"monsters\":{\"Drudge Skulker\":{\"name\":\"Drudge Skulker\",\"kill_count\":0,\"damage_given\":25,"
|
||||||
|
+ "\"damage_received\":0,\"aetheria_surges\":0,\"cloak_surges\":0,"
|
||||||
|
+ "\"offense\":{\"MeleeMissile\":{\"Slash\":{\"total_attacks\":1,\"failed_attacks\":0,\"crits\":0,"
|
||||||
|
+ "\"total_normal_damage\":25,\"max_normal_damage\":25,\"total_crit_damage\":0,\"max_crit_damage\":0,"
|
||||||
|
+ "\"killing_blows\":0,\"damage\":25}}},\"defense\":{},"
|
||||||
|
+ "\"TotalOffenseAttacks\":1,\"TotalOffenseFailedAttacks\":0,\"TotalOffenseCrits\":0,"
|
||||||
|
+ "\"TotalOffenseNormalDamage\":25,\"MaxOffenseNormalDamage\":25,\"TotalOffenseCritDamage\":0,"
|
||||||
|
+ "\"MaxOffenseCritDamage\":0,\"TotalDefenseAttacks\":0,\"TotalDefenseFailedAttacks\":0}}},"
|
||||||
|
+ "\"lifetime\":null}",
|
||||||
|
Assert.Single(harness.Frames));
|
||||||
|
|
||||||
|
// Nothing changed since: no frame.
|
||||||
|
harness.Frames.Clear();
|
||||||
|
tracker.OnSendTick();
|
||||||
|
Assert.Empty(harness.Frames);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Kills_count_from_kill_messages_and_the_report_answers_in_allegiance()
|
||||||
|
{
|
||||||
|
using var harness = LoggedIn();
|
||||||
|
var stats = harness.Feature<SessionStats>();
|
||||||
|
|
||||||
|
harness.Host.Automation.Chat.Deliver("You killed Drudge Skulker!", kind: 8, logTextType: 6);
|
||||||
|
harness.Host.Automation.Chat.Deliver("Drudge Skulker is torn to ribbons by your assault!", kind: 8, logTextType: 6);
|
||||||
|
Assert.Equal(2, stats.Kills.TotalKills);
|
||||||
|
|
||||||
|
harness.Host.Automation.Chat.Deliver("!report", kind: 2, sender: "Horan", channelName: "Allegiance", logTextType: 18);
|
||||||
|
|
||||||
|
string report = Assert.Single(harness.Host.Automation.Chat.Submitted);
|
||||||
|
Assert.StartsWith("/a Total Kills: 2, Kills per Hour: ", report);
|
||||||
|
Assert.EndsWith(", Elapsed Time: 00.00:00:00, Rares Found: 0", report);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Death_counts_a_session_death_and_rereads_total_deaths()
|
||||||
|
{
|
||||||
|
using var harness = LoggedIn();
|
||||||
|
var stats = harness.Feature<SessionStats>();
|
||||||
|
harness.Host.Automation.Objects.Properties[0x50000001] = new PluginItemProperties(
|
||||||
|
new Dictionary<uint, int> { [43] = 12 },
|
||||||
|
new Dictionary<uint, long>(), new Dictionary<uint, bool>(), new Dictionary<uint, double>(),
|
||||||
|
new Dictionary<uint, string>(), new Dictionary<uint, uint>(), new Dictionary<uint, uint>());
|
||||||
|
|
||||||
|
harness.Host.Events.RaiseLocalPlayerDied("You died.");
|
||||||
|
|
||||||
|
Assert.Equal(1, stats.Kills.SessionDeaths);
|
||||||
|
Assert.Equal(12, stats.Kills.TotalDeaths);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue