diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e79350c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.cs diff=csharp diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs index 697ab4e..158a6f4 100644 --- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs +++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs @@ -1,4 +1,7 @@ using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Infrastructure; +using OpenAC.MosswartMassacre.Streams; +using OpenAC.MosswartMassacre.Trackers; namespace OpenAC.MosswartMassacre; @@ -10,8 +13,20 @@ internal static class FeatureCatalog { public static void AddFeatures(MmContext context, BackendSession session, List features) { - _ = context; - _ = session; - _ = features; + // Streams and session counters. + var delayed = new DelayedCommands(context); + 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); } } diff --git a/src/OpenAC.MosswartMassacre/Infrastructure/DelayedCommands.cs b/src/OpenAC.MosswartMassacre/Infrastructure/DelayedCommands.cs new file mode 100644 index 0000000..3e45ccd --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Infrastructure/DelayedCommands.cs @@ -0,0 +1,38 @@ +namespace OpenAC.MosswartMassacre.Infrastructure; + +/// +/// 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. +/// +internal sealed class DelayedCommands(MmContext context) +{ + private readonly List _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); + } + + /// Drops every command not yet run. + public void Clear() + { + foreach (IDisposable handle in _pending) + handle.Dispose(); + _pending.Clear(); + } +} diff --git a/src/OpenAC.MosswartMassacre/Infrastructure/MmCoordinates.cs b/src/OpenAC.MosswartMassacre/Infrastructure/MmCoordinates.cs new file mode 100644 index 0000000..13d0941 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Infrastructure/MmCoordinates.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Infrastructure; + +/// +/// Map coordinates computed exactly as the original computed them for the +/// telemetry, rare and sharing frames. +/// +/// +/// 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. +/// +internal readonly record struct MmCoordinates(double EW, double NS, double Z) +{ + /// The original's conversion of a position, float steps included. + 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); + } + + /// The original's east-west formula: a float, from the cell's landblock column. + public static float LandblockToEW(uint landcell, float xOff) + { + uint l = (landcell & 0xFF000000) / 0x200000; + return (float)(((xOff / 24) + l - 1019.5) / 10); + } + + /// The original's north-south formula: a float, from the cell's landblock row. + public static float LandblockToNS(uint landcell, float yOff) + { + uint l = (landcell & 0x00FF0000) / 0x2000; + return (float)(((yOff / 24) + l - 1019.5) / 10); + } + + /// Flat distance in metres between two map coordinates, as the original measured it. + 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')}"); +} diff --git a/src/OpenAC.MosswartMassacre/Infrastructure/ProcessTelemetry.cs b/src/OpenAC.MosswartMassacre/Infrastructure/ProcessTelemetry.cs new file mode 100644 index 0000000..d686d23 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Infrastructure/ProcessTelemetry.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; + +namespace OpenAC.MosswartMassacre.Infrastructure; + +/// +/// The client process's own numbers for the telemetry frame: working set in +/// bytes, open handles, and CPU use in percent of all cores. +/// +/// +/// 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. +/// +internal sealed class ProcessTelemetry +{ + private readonly Process _process = Process.GetCurrentProcess(); + private TimeSpan _lastCpu; + private DateTime _lastWallUtc; + + public ProcessTelemetry() + { + _lastCpu = _process.TotalProcessorTime; + _lastWallUtc = DateTime.UtcNow; + } + + /// Working-set memory in bytes (the original's mem_mb, despite the name). + public long MemoryBytes + { + get + { + _process.Refresh(); + return _process.WorkingSet64; + } + } + + public int HandleCount + { + get + { + _process.Refresh(); + return _process.HandleCount; + } + } + + /// CPU use since the previous call, in percent of all cores. + 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); + } +} diff --git a/src/OpenAC.MosswartMassacre/Infrastructure/TickScheduler.cs b/src/OpenAC.MosswartMassacre/Infrastructure/TickScheduler.cs index a9690e8..37074fc 100644 --- a/src/OpenAC.MosswartMassacre/Infrastructure/TickScheduler.cs +++ b/src/OpenAC.MosswartMassacre/Infrastructure/TickScheduler.cs @@ -13,6 +13,9 @@ namespace OpenAC.MosswartMassacre.Infrastructure; /// 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 IPluginLogger _log; private readonly Action _handler; @@ -58,7 +61,7 @@ internal sealed class TickScheduler : IDisposable _due.Clear(); foreach (Entry entry in _entries) { - if (!entry.Cancelled && entry.DueAt <= ElapsedSeconds) + if (!entry.Cancelled && entry.DueAt <= ElapsedSeconds + DueTolerance) _due.Add(entry); } @@ -67,9 +70,18 @@ internal sealed class TickScheduler : IDisposable if (entry.Cancelled) continue; if (entry.Repeat) - entry.DueAt = ElapsedSeconds + entry.IntervalSeconds; + { + // 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; + } else + { entry.Cancelled = true; + } try { entry.Callback(); diff --git a/src/OpenAC.MosswartMassacre/MmContext.cs b/src/OpenAC.MosswartMassacre/MmContext.cs index 05f3dd2..dd44b63 100644 --- a/src/OpenAC.MosswartMassacre/MmContext.cs +++ b/src/OpenAC.MosswartMassacre/MmContext.cs @@ -62,6 +62,15 @@ internal sealed class MmContext /// public string ConnectionSessionId { get; set; } = string.Empty; + /// The prismatic taper count for telemetry; the inventory feature supplies it. + public Func PrismaticTaperCount { get; set; } = static () => 0; + + /// The bot's current meta state name for telemetry, or empty when there is none. + public Func MetaState { get; set; } = static () => string.Empty; + + /// The character's vitae penalty in percent (0 when none), as the original's vitals frame carried it. + public Func Vitae { get; set; } = static () => 0; + /// The per-process id the original put in every telemetry frame. public static string ProcessSessionId { get; } = Guid.NewGuid().ToString("N"); diff --git a/src/OpenAC.MosswartMassacre/Streams/BotControl.cs b/src/OpenAC.MosswartMassacre/Streams/BotControl.cs new file mode 100644 index 0000000..012a4be --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Streams/BotControl.cs @@ -0,0 +1,36 @@ +namespace OpenAC.MosswartMassacre.Streams; + +/// +/// The original's commands that drove the bot. They go through the bot's own +/// /vt verbs, so they work with whichever bot plugin answers to them. +/// +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?"); + } +} diff --git a/src/OpenAC.MosswartMassacre/Streams/ChatLineText.cs b/src/OpenAC.MosswartMassacre/Streams/ChatLineText.cs new file mode 100644 index 0000000..4afc8df --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Streams/ChatLineText.cs @@ -0,0 +1,47 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Streams; + +/// +/// 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. +/// +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}\""; + } +} diff --git a/src/OpenAC.MosswartMassacre/Streams/ChatRouter.cs b/src/OpenAC.MosswartMassacre/Streams/ChatRouter.cs new file mode 100644 index 0000000..117be3d --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Streams/ChatRouter.cs @@ -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; + +/// +/// Every chat line, in the original's order: first the kill counter, the combat +/// tracker, the rare tracker and the allegiance !report reply read it; +/// then the line goes to the backend as a chat frame. +/// +internal sealed class ChatRouter : IMmFeature +{ + /// The retail text class of allegiance chat, as the original compared it. + public const int AllegianceColor = 18; + + private readonly MmContext _context; + private readonly KillTracker _kills; + private readonly CombatStatsTracker _combat; + private readonly RareTracker _rares; + private readonly Action _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, + }); + + /// The original's clean-up: tags removed, trailing line breaks trimmed, runs of spaces collapsed. + 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,}", " "); + } +} diff --git a/src/OpenAC.MosswartMassacre/Streams/SessionStats.cs b/src/OpenAC.MosswartMassacre/Streams/SessionStats.cs new file mode 100644 index 0000000..3b9aaee --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Streams/SessionStats.cs @@ -0,0 +1,129 @@ +using System.Globalization; +using OpenAC.MosswartMassacre.Infrastructure; +using OpenAC.MosswartMassacre.Trackers; + +namespace OpenAC.MosswartMassacre.Streams; + +/// +/// 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. +/// +internal sealed class SessionStats : IMmFeature +{ + /// The player's number-of-deaths property. + public const uint NumDeathsProperty = 43; + + private readonly MmContext _context; + private readonly Action _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")}"); + } +} diff --git a/src/OpenAC.MosswartMassacre/Streams/SpawnPortalStream.cs b/src/OpenAC.MosswartMassacre/Streams/SpawnPortalStream.cs new file mode 100644 index 0000000..c7e9023 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Streams/SpawnPortalStream.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Infrastructure; + +namespace OpenAC.MosswartMassacre.Streams; + +/// +/// The spawn frame for every monster that appears and the +/// portal frame for every portal that appears within 240 metres, with +/// the coordinates as strings the way the original formatted them. +/// +internal sealed class SpawnPortalStream : IMmFeature +{ + public const double PortalRangeMeters = 240.0; + + private readonly MmContext _context; + private readonly Action _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); + } +} diff --git a/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs b/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs new file mode 100644 index 0000000..8c01021 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs @@ -0,0 +1,84 @@ +using System.Globalization; +using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Infrastructure; + +namespace OpenAC.MosswartMassacre.Streams; + +/// +/// The telemetry frame: sent right after the socket registers and then +/// every five seconds, as the original's socket loop did. +/// +/// +/// Several fields are strings on the wire (kill rate, deaths, taper count) and +/// mem_mb carries bytes; the backend reads them that way. +/// +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, + }; + } +} diff --git a/src/OpenAC.MosswartMassacre/Streams/VitalsStream.cs b/src/OpenAC.MosswartMassacre/Streams/VitalsStream.cs new file mode 100644 index 0000000..6f98f6e --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Streams/VitalsStream.cs @@ -0,0 +1,55 @@ +using OpenAC.MosswartMassacre.Backend; + +namespace OpenAC.MosswartMassacre.Streams; + +/// +/// The vitals frame every five seconds while streaming is on. The +/// backend watches vitae going from 0 to above 0 to notice a death. +/// +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(), + }); + } +} diff --git a/src/OpenAC.MosswartMassacre/Trackers/CombatInfo.cs b/src/OpenAC.MosswartMassacre/Trackers/CombatInfo.cs new file mode 100644 index 0000000..62307cb --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Trackers/CombatInfo.cs @@ -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, + } + + /// + /// Per-element damage statistics within a single attack type. + /// Mirrors Mag-Tools CombatInfo.DamageByAttackType.DamageByElement. + /// + public class DamageStats + { + [JsonProperty("total_attacks")] public int TotalAttacks; + [JsonProperty("failed_attacks")] public int FailedAttacks; + [JsonProperty("crits")] public int Crits; + [JsonProperty("total_normal_damage")] public int TotalNormalDamage; + [JsonProperty("max_normal_damage")] public int MaxNormalDamage; + [JsonProperty("total_crit_damage")] public int TotalCritDamage; + [JsonProperty("max_crit_damage")] public int MaxCritDamage; + [JsonProperty("killing_blows")] public int KillingBlows; + + [JsonProperty("damage")] + public int Damage => TotalNormalDamage + TotalCritDamage; + } + + /// + /// Per-monster combat record. Stores offense and defense breakdowns + /// keyed by AttackType → DamageElement → DamageStats. Mirrors + /// Mag-Tools CombatInfo but without the source/target pair split + /// (we always track relative to the local character). + /// + public class MonsterCombatRecord + { + [JsonProperty("name")] public string Name; + [JsonProperty("kill_count")] public int KillCount; + [JsonProperty("damage_given")] public long DamageGiven; + [JsonProperty("damage_received")] public long DamageReceived; + [JsonProperty("aetheria_surges")] public int AetheriaSurges; + [JsonProperty("cloak_surges")] public int CloakSurges; + + // offense[AttackType][DamageElement] = stats for damage WE dealt + [JsonProperty("offense")] + public Dictionary> Offense + = new Dictionary>(); + + // defense[AttackType][DamageElement] = stats for damage WE received + [JsonProperty("defense")] + public Dictionary> Defense + = new Dictionary>(); + + public DamageStats GetOrCreateStats( + Dictionary> side, + AttackType at, DamageElement el) + { + if (!side.TryGetValue(at, out var byElement)) + { + byElement = new Dictionary(); + side[at] = byElement; + } + if (!byElement.TryGetValue(el, out var stats)) + { + stats = new DamageStats(); + byElement[el] = stats; + } + return stats; + } + + // ── Aggregate helpers matching Mag-Tools CombatTrackerGUI calculations ── + + public int TotalOffenseAttacks => + Offense.Values.SelectMany(d => d.Values).Sum(s => s.TotalAttacks); + public int TotalOffenseFailedAttacks => + Offense.Values.SelectMany(d => d.Values).Sum(s => s.FailedAttacks); + public int TotalOffenseCrits => + Offense.Values.SelectMany(d => d.Values).Sum(s => s.Crits); + public long TotalOffenseNormalDamage => + Offense.Values.SelectMany(d => d.Values).Sum(s => (long)s.TotalNormalDamage); + public int MaxOffenseNormalDamage => + Offense.Values.SelectMany(d => d.Values).Select(s => s.MaxNormalDamage).DefaultIfEmpty(0).Max(); + public long TotalOffenseCritDamage => + Offense.Values.SelectMany(d => d.Values).Sum(s => (long)s.TotalCritDamage); + public int MaxOffenseCritDamage => + Offense.Values.SelectMany(d => d.Values).Select(s => s.MaxCritDamage).DefaultIfEmpty(0).Max(); + + public int TotalDefenseAttacks => + Defense.Values.SelectMany(d => d.Values).Sum(s => s.TotalAttacks); + public int TotalDefenseFailedAttacks => + Defense.Values.SelectMany(d => d.Values).Sum(s => s.FailedAttacks); + + // Defense damage by element for the breakdown grid + public int GetDefenseDamage(AttackType at, DamageElement el) + { + if (!Defense.TryGetValue(at, out var byEl)) return 0; + if (!byEl.TryGetValue(el, out var stats)) return 0; + return stats.Damage; + } + } + + /// + /// Holds all combat data for a single session (or lifetime accumulation). + /// Serializes cleanly to JSON for the WebSocket combat_stats envelope. + /// + public class CombatSessionState + { + [JsonProperty("total_damage_given")] public long TotalDamageGiven; + [JsonProperty("total_damage_received")] public long TotalDamageReceived; + [JsonProperty("total_kills")] public int TotalKills; + [JsonProperty("total_aetheria_surges")] public int TotalAetheriaSurges; + [JsonProperty("total_cloak_surges")] public int TotalCloakSurges; + [JsonProperty("session_start")] public string SessionStart; + + [JsonProperty("monsters")] + public Dictionary Monsters + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public MonsterCombatRecord GetOrCreateMonster(string name) + { + if (!Monsters.TryGetValue(name, out var rec)) + { + rec = new MonsterCombatRecord { Name = name }; + Monsters[name] = rec; + } + return rec; + } + + public void Clear() + { + TotalDamageGiven = 0; + TotalDamageReceived = 0; + TotalKills = 0; + TotalAetheriaSurges = 0; + TotalCloakSurges = 0; + SessionStart = DateTime.UtcNow.ToString("o"); + Monsters.Clear(); + } + } +} diff --git a/src/OpenAC.MosswartMassacre/Trackers/CombatStatsTracker.cs b/src/OpenAC.MosswartMassacre/Trackers/CombatStatsTracker.cs new file mode 100644 index 0000000..76d993b --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Trackers/CombatStatsTracker.cs @@ -0,0 +1,394 @@ +using System.Text.RegularExpressions; +using OpenAC.MosswartMassacre.Backend; + +namespace OpenAC.MosswartMassacre.Trackers; + +/// +/// Parses combat chat into per-monster offense and defense statistics and +/// sends a combat_stats 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. +/// +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 (?.+)!$", RegexOptions.Compiled), + new Regex(@"^(?.+) evaded your attack\.$", RegexOptions.Compiled), + new Regex(@"^You resist the spell cast by (?.+)$", RegexOptions.Compiled), + new Regex(@"^(?.+) resists your spell$", RegexOptions.Compiled), + ]; + + private bool TryParseFailedAttack(string text, string myName) + { + foreach (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! (?.+) [\w]+ your .+ for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + new Regex(@"^Critical hit! (?.+) [\w]+ your .+ for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + new Regex(@"^Overpower! (?.+) [\w]+ your .+ for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + new Regex(@"^(?.+) [\w]+ your .+ for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + ]; + + private static readonly Regex[] MeleeMissileGiven = + [ + new Regex(@"^Critical hit!\s+Sneak Attack! Recklessness! You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + new Regex(@"^Critical hit!\s+Sneak Attack! You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + new Regex(@"^Critical hit!\s+You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + new Regex(@"^Sneak Attack! Recklessness! You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + new Regex(@"^Sneak Attack! You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + new Regex(@"^Recklessness! You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + new Regex(@"^You [\w]+ (?.+) for (?\d+) point.* of .+ damage", RegexOptions.Compiled), + ]; + + private static readonly Regex[] MagicReceived = + [ + new Regex(@"^Critical hit! Overpower! (?.+) [\w]+ you for (?\d+) point.* with .+$", RegexOptions.Compiled), + new Regex(@"^Critical hit! (?.+) [\w]+ you for (?\d+) point.* with .+$", RegexOptions.Compiled), + new Regex(@"^Overpower! (?.+) [\w]+ you for (?\d+) point.* with .+$", RegexOptions.Compiled), + new Regex(@"^(?.+) [\w]+ you for (?\d+) point.* with .+$", RegexOptions.Compiled), + new Regex(@"^Magical energies lose (?\d+) point.* of health due to (?.+) casting .+$", RegexOptions.Compiled), + new Regex(@"^You lose (?\d+) point.* of health due to (?.+) casting .+$", RegexOptions.Compiled), + new Regex(@"^(?.+) casts .+ and drains (?\d+) point.* .+$", RegexOptions.Compiled), + ]; + + private static readonly Regex[] MagicGiven = + [ + new Regex(@"^Critical hit! You [\w]+ (?.+) for (?\d+) point.* with .+$", RegexOptions.Compiled), + new Regex(@"^You [\w]+ (?.+) for (?\d+) point.* with .+$", RegexOptions.Compiled), + ]; + + private static readonly Regex AetheriaSurge = + new(@"^Aetheria surges on (?.+) 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("The ten-second send: one snapshot when anything changed since the last. + 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); + } +} diff --git a/src/OpenAC.MosswartMassacre/Trackers/KillTracker.cs b/src/OpenAC.MosswartMassacre/Trackers/KillTracker.cs new file mode 100644 index 0000000..f34e23b --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Trackers/KillTracker.cs @@ -0,0 +1,119 @@ +using System.Text.RegularExpressions; +using OpenAC.MosswartMassacre.Infrastructure; + +namespace OpenAC.MosswartMassacre.Trackers; + +/// +/// Kills, deaths and kill rate for the session, from the player's own kill +/// messages. The pattern list is the original's, unchanged. +/// +internal sealed class KillTracker(IClock clock) +{ + // Kill message patterns, preserved exactly. + internal static readonly string[] KillPatterns = + [ + @"^You flatten (?.+)'s body with the force of your assault!$", + @"^You bring (?.+) to a fiery end!$", + @"^You beat (?.+) to a lifeless pulp!$", + @"^You smite (?.+) mightily!$", + @"^You obliterate (?.+)!$", + @"^You run (?.+) through!$", + @"^You reduce (?.+) to a sizzling, oozing mass!$", + @"^You knock (?.+) into next Morningthaw!$", + @"^You split (?.+) apart!$", + @"^You cleave (?.+) in twain!$", + @"^You slay (?.+) viciously enough to impart death several times over!$", + @"^You reduce (?.+) to a drained, twisted corpse!$", + @"^Your killing blow nearly turns (?.+) inside-out!$", + @"^Your attack stops (?.+) cold!$", + @"^Your lightning coruscates over (?.+)'s mortal remains!$", + @"^Your assault sends (?.+) to an icy death!$", + @"^You killed (?.+)!$", + @"^The thunder of crushing (?.+) is followed by the deafening silence of death!$", + @"^The deadly force of your attack is so strong that (?.+)'s ancestors feel it!$", + @"^(?.+)'s seared corpse smolders before you!$", + @"^(?.+) is reduced to cinders!$", + @"^(?.+) is shattered by your assault!$", + @"^(?.+) catches your attack, with dire consequences!$", + @"^(?.+) is utterly destroyed by your attack!$", + @"^(?.+) suffers a frozen fate!$", + @"^(?.+)'s perforated corpse falls before you!$", + @"^(?.+) is fatally punctured!$", + @"^(?.+)'s death is preceded by a sharp, stabbing pain!$", + @"^(?.+) is torn to ribbons by your assault!$", + @"^(?.+) is liquified by your attack!$", + @"^(?.+)'s last strength dissolves before you!$", + @"^Electricity tears (?.+) apart!$", + @"^Blistered by lightning, (?.+) falls!$", + @"^(?.+)'s last strength withers before you!$", + @"^(?.+) is dessicated by your attack!$", + @"^(?.+) 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; } + + /// Counts a kill when is one of the player's kill messages. + 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; + } + + /// The once-a-second refresh the original ran on its stats timer. + 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; + } +} diff --git a/src/OpenAC.MosswartMassacre/Trackers/RareTracker.cs b/src/OpenAC.MosswartMassacre/Trackers/RareTracker.cs new file mode 100644 index 0000000..194dfe7 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Trackers/RareTracker.cs @@ -0,0 +1,48 @@ +using System.Text.RegularExpressions; +using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Infrastructure; + +namespace OpenAC.MosswartMassacre.Trackers; + +/// +/// The player's own rare discoveries: counts them, announces the rare in +/// allegiance chat three seconds later and sends the rare frame. (The +/// original could also switch the bot's meta state on a rare; that is not +/// carried over.) +/// +internal sealed class RareTracker(MmContext context, DelayedCommands delayed) +{ + private static readonly Regex RarePattern = + new(@"^(?['A-Za-z ]+)\shas discovered the (?.*?)!$", 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, + }); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs new file mode 100644 index 0000000..c8152b3 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs @@ -0,0 +1,329 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Streams; +using OpenAC.MosswartMassacre.Trackers; + +namespace OpenAC.MosswartMassacre.Tests.Streams; + +/// +/// 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. +/// +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"; + + /// + /// 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. + /// + 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? 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().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 { [43] = 7 }, + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary())); + 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().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 " 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(); + + 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(); + + 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(); + harness.Host.Automation.Objects.Properties[0x50000001] = new PluginItemProperties( + new Dictionary { [43] = 12 }, + new Dictionary(), new Dictionary(), new Dictionary(), + new Dictionary(), new Dictionary(), new Dictionary()); + + harness.Host.Events.RaiseLocalPlayerDied("You died."); + + Assert.Equal(1, stats.Kills.SessionDeaths); + Assert.Equal(12, stats.Kills.TotalDeaths); + } +}