diff --git a/MosswartMassacre/ChatManager.cs b/MosswartMassacre/ChatManager.cs
new file mode 100644
index 0000000..18a9191
--- /dev/null
+++ b/MosswartMassacre/ChatManager.cs
@@ -0,0 +1,140 @@
+using System;
+using System.Text.RegularExpressions;
+using Decal.Adapter;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Handles game chat events: kill/rare detection, forwarding chat to WebSocket, and "/mm" command parsing.
+ ///
+ internal class ChatManager : IDisposable
+ {
+ private readonly StatsManager _statsManager;
+ private readonly IWebSocketService _wsService;
+ private readonly Action _mmCommandHandler;
+ private bool _isStarted;
+
+ ///
+ /// Constructs a new ChatManager.
+ ///
+ /// Stats manager for registering kills/rares.
+ /// Delegate to handle '/mm' commands.
+ public ChatManager(StatsManager statsManager, IWebSocketService wsService, Action mmCommandHandler)
+ {
+ _statsManager = statsManager ?? throw new ArgumentNullException(nameof(statsManager));
+ _wsService = wsService ?? throw new ArgumentNullException(nameof(wsService));
+ _mmCommandHandler = mmCommandHandler;
+ }
+
+ ///
+ /// Subscribes to chat and command events.
+ ///
+ public void Start()
+ {
+ if (_isStarted) return;
+ _isStarted = true;
+ CoreManager.Current.ChatBoxMessage += OnChatIntercept;
+ CoreManager.Current.CommandLineText += OnChatCommand;
+ }
+
+ ///
+ /// Unsubscribes from chat and command events.
+ ///
+ public void Stop()
+ {
+ if (!_isStarted) return;
+ CoreManager.Current.ChatBoxMessage -= OnChatIntercept;
+ CoreManager.Current.CommandLineText -= OnChatCommand;
+ _isStarted = false;
+ }
+
+ private void OnChatIntercept(object sender, ChatTextInterceptEventArgs e)
+ {
+ // Forward chat text asynchronously to WebSocket
+ try
+ {
+ string cleaned = NormalizeChatLine(e.Text);
+ _ = _wsService.SendChatTextAsync(e.Color, cleaned);
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat("Error sending chat over WS: " + ex.Message);
+ }
+
+ // Kill detection
+ try
+ {
+ if (PluginCore.IsKilledByMeMessage(e.Text))
+ {
+ _statsManager.RegisterKill();
+ }
+ // Rare discovery detection
+ if (PluginCore.IsRareDiscoveryMessage(e.Text, out string rareText))
+ {
+ _statsManager.RegisterRare();
+ if (PluginSettings.Instance.RareMetaEnabled)
+ PluginCore.Decal_DispatchOnChatCommand("/vt setmetastate loot_rare");
+ DelayedCommandManager.AddDelayedCommand($"/a {rareText}", 3000);
+ }
+ // Remote commands from allegiance
+ if (PluginSettings.Instance.RemoteCommandsEnabled && e.Color == 18)
+ {
+ string characterName = Regex.Escape(CoreManager.Current.CharacterFilter.Name);
+ string pattern = $"^\\[Allegiance\\].*Dunking Rares.*say[s]?, \\\"!do {characterName} (?.+)\\\"$";
+ string tag = Regex.Escape(PluginCore.CharTag);
+ string patterntag = $"^\\[Allegiance\\].*Dunking Rares.*say[s]?, \\\"!dot {tag} (?.+)\\\"$";
+ var match = Regex.Match(e.Text, pattern);
+ var matchtag = Regex.Match(e.Text, patterntag);
+ if (match.Success)
+ {
+ string command = match.Groups["command"].Value;
+ PluginCore.Decal_DispatchOnChatCommand(command);
+ DelayedCommandManager.AddDelayedCommand($"/a [Remote] Executing: {command}", 2000);
+ }
+ else if (matchtag.Success)
+ {
+ string command = matchtag.Groups["command"].Value;
+ PluginCore.Decal_DispatchOnChatCommand(command);
+ DelayedCommandManager.AddDelayedCommand($"/a [Remote] Executing: {command}", 2000);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat("Error processing chat message: " + ex.Message);
+ }
+ }
+
+ private void OnChatCommand(object sender, ChatParserInterceptEventArgs e)
+ {
+ try
+ {
+ if (e.Text.StartsWith("/mm", StringComparison.OrdinalIgnoreCase))
+ {
+ e.Eat = true;
+ _mmCommandHandler?.Invoke(e.Text);
+ }
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat("[Error] Failed to process /mm command: " + ex.Message);
+ }
+ }
+
+ private static string NormalizeChatLine(string raw)
+ {
+ if (string.IsNullOrEmpty(raw)) return raw;
+ // Remove tags
+ string noTags = Regex.Replace(raw, "<[^>]+>", "");
+ // Trim newlines
+ string trimmed = noTags.TrimEnd('\r', '\n');
+ // Collapse spaces
+ return Regex.Replace(trimmed, "[ ]{2,}", " ");
+ }
+
+ public void Dispose()
+ {
+ Stop();
+ }
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/CommandHandler.cs b/MosswartMassacre/CommandHandler.cs
new file mode 100644
index 0000000..99fdc9a
--- /dev/null
+++ b/MosswartMassacre/CommandHandler.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Text.RegularExpressions;
+using Decal.Adapter;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Handles "/mm" commands from chat.
+ ///
+ public class CommandHandler : ICommandHandler
+ {
+ private readonly StatsManager _statsManager;
+ private readonly IWebSocketService _wsService;
+
+ public CommandHandler(StatsManager statsManager, IWebSocketService wsService)
+ {
+ _statsManager = statsManager ?? throw new ArgumentNullException(nameof(statsManager));
+ _wsService = wsService ?? throw new ArgumentNullException(nameof(wsService));
+ }
+
+ public void Handle(string commandText)
+ {
+ // Remove the /mm prefix and trim
+ string[] args = commandText.Length > 3
+ ? commandText.Substring(3).Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)
+ : Array.Empty();
+ if (args.Length == 0)
+ {
+ PluginCore.WriteToChat("Usage: /mm . Try /mm help");
+ return;
+ }
+
+ string sub = args[0].ToLowerInvariant();
+ switch (sub)
+ {
+ case "ws":
+ if (args.Length > 1)
+ {
+ if (args[1].Equals("enable", StringComparison.OrdinalIgnoreCase))
+ {
+ _wsService.Start();
+ PluginSettings.Instance.WebSocketEnabled = true;
+ PluginCore.WriteToChat("WS streaming ENABLED.");
+ }
+ else if (args[1].Equals("disable", StringComparison.OrdinalIgnoreCase))
+ {
+ _wsService.Stop();
+ PluginSettings.Instance.WebSocketEnabled = false;
+ PluginCore.WriteToChat("WS streaming DISABLED.");
+ }
+ else
+ {
+ PluginCore.WriteToChat("Usage: /mm ws ");
+ }
+ }
+ else
+ {
+ PluginCore.WriteToChat("Usage: /mm ws ");
+ }
+ break;
+
+ case "help":
+ PluginCore.WriteToChat("Mosswart Massacre Commands:");
+ PluginCore.WriteToChat("/mm report - Show current stats");
+ PluginCore.WriteToChat("/mm loc - Show current location");
+ PluginCore.WriteToChat("/mm ws - Websocket streaming enable|disable");
+ PluginCore.WriteToChat("/mm reset - Reset all counters");
+ PluginCore.WriteToChat("/mm meta - Toggle rare meta state");
+ PluginCore.WriteToChat("/mm http - Local http-command server enable|disable");
+ PluginCore.WriteToChat("/mm remotecommand - Listen to allegiance !do/!dot enable|disable");
+ PluginCore.WriteToChat("/mm getmetastate - Gets the current metastate");
+ break;
+
+ case "report":
+ var elapsed = DateTime.Now - _statsManager.StatsStartTime;
+ var report = $"Total Kills: {_statsManager.TotalKills}, Kills per Hour: {_statsManager.KillsPerHour:F2}, Elapsed Time: {elapsed:dd\.hh\:mm\:ss}, Rares Found: {_statsManager.RareCount}";
+ PluginCore.WriteToChat(report);
+ break;
+
+ case "getmetastate":
+ var state = VtankControl.VtGetMetaState();
+ PluginCore.WriteToChat(state);
+ break;
+
+ case "loc":
+ var here = Coordinates.Me;
+ var pos = Utils.GetPlayerPosition();
+ PluginCore.WriteToChat($"Location: {here} (X={pos.X:F1}, Y={pos.Y:F1}, Z={pos.Z:F1})");
+ break;
+
+ case "reset":
+ _statsManager.Restart();
+ PluginCore.WriteToChat("Stats have been reset.");
+ break;
+
+ case "meta":
+ PluginSettings.Instance.RareMetaEnabled = !PluginSettings.Instance.RareMetaEnabled;
+ PluginCore.WriteToChat($"Rare meta state is now {(PluginSettings.Instance.RareMetaEnabled ? "ON" : "OFF")}");
+ MainView.SetRareMetaToggleState(PluginSettings.Instance.RareMetaEnabled);
+ break;
+
+ case "http":
+ if (args.Length > 1)
+ {
+ if (args[1].Equals("enable", StringComparison.OrdinalIgnoreCase))
+ {
+ PluginSettings.Instance.HttpServerEnabled = true;
+ HttpCommandServer.Start();
+ }
+ else if (args[1].Equals("disable", StringComparison.OrdinalIgnoreCase))
+ {
+ PluginSettings.Instance.HttpServerEnabled = false;
+ HttpCommandServer.Stop();
+ }
+ else
+ {
+ PluginCore.WriteToChat("Usage: /mm http ");
+ }
+ }
+ else
+ {
+ PluginCore.WriteToChat("Usage: /mm http ");
+ }
+ break;
+
+ case "remotecommands":
+ PluginSettings.Instance.RemoteCommandsEnabled = !PluginSettings.Instance.RemoteCommandsEnabled;
+ PluginCore.WriteToChat($"Remote command listening is now {(PluginSettings.Instance.RemoteCommandsEnabled ? "ENABLED" : "DISABLED")}.");
+ break;
+
+ default:
+ PluginCore.WriteToChat($"Unknown /mm command: {sub}. Try /mm help");
+ break;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/DelayedCommandManager.cs b/MosswartMassacre/DelayedCommandManager.cs
index 4976e4b..646566a 100644
--- a/MosswartMassacre/DelayedCommandManager.cs
+++ b/MosswartMassacre/DelayedCommandManager.cs
@@ -9,7 +9,7 @@ namespace MosswartMassacre
static List delayedCommands = new List();
static bool isDelayListening = false;
- public static void AddDelayedCommand(string command, double delay)
+ public static void AddDelayedCommand(string command, double delay)
{
var delayed = new DelayedCommand(command, delay);
delayedCommands.Add(delayed);
@@ -44,6 +44,18 @@ namespace MosswartMassacre
PluginCore.WriteToChat("Error in delayed command system: " + ex.Message);
}
}
+ ///
+ /// Shutdown the delayed command system: unsubscribe from RenderFrame and clear pending commands.
+ ///
+ public static void Shutdown()
+ {
+ if (isDelayListening)
+ {
+ CoreManager.Current.RenderFrame -= Core_RenderFrame_Delay;
+ isDelayListening = false;
+ }
+ delayedCommands.Clear();
+ }
}
public class DelayedCommand
diff --git a/MosswartMassacre/ICommandHandler.cs b/MosswartMassacre/ICommandHandler.cs
new file mode 100644
index 0000000..f769430
--- /dev/null
+++ b/MosswartMassacre/ICommandHandler.cs
@@ -0,0 +1,14 @@
+namespace MosswartMassacre
+{
+ ///
+ /// Handles plugin commands (e.g., /mm commands).
+ ///
+ public interface ICommandHandler
+ {
+ ///
+ /// Processes a command text, e.g. "/mm report".
+ ///
+ /// The full command text.
+ void Handle(string commandText);
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/IStreamService.cs b/MosswartMassacre/IStreamService.cs
new file mode 100644
index 0000000..7c3ca03
--- /dev/null
+++ b/MosswartMassacre/IStreamService.cs
@@ -0,0 +1,18 @@
+namespace MosswartMassacre
+{
+ ///
+ /// Represents a start/stop service with life-cycle management.
+ ///
+ public interface IStreamService : System.IDisposable
+ {
+ ///
+ /// Starts the service.
+ ///
+ void Start();
+
+ ///
+ /// Stops the service.
+ ///
+ void Stop();
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/IWebSocketService.cs b/MosswartMassacre/IWebSocketService.cs
new file mode 100644
index 0000000..23933dd
--- /dev/null
+++ b/MosswartMassacre/IWebSocketService.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Threading.Tasks;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Service for WebSocket streaming and command handling.
+ ///
+ public interface IWebSocketService : IStreamService
+ {
+ ///
+ /// Fires when a valid command envelope arrives from the server for this character.
+ ///
+ event Action OnServerCommand;
+
+ ///
+ /// Sends chat text asynchronously over the WebSocket.
+ ///
+ /// Chat color index.
+ /// Text to send.
+ Task SendChatTextAsync(int colorIndex, string chatText);
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/MosswartMassacre.csproj b/MosswartMassacre/MosswartMassacre.csproj
index 33a1c87..6602520 100644
--- a/MosswartMassacre/MosswartMassacre.csproj
+++ b/MosswartMassacre/MosswartMassacre.csproj
@@ -76,7 +76,6 @@
-
@@ -92,7 +91,14 @@
Resources.resx
-
+
+
+
+
+
+
+
+
diff --git a/MosswartMassacre/PluginCore.cs b/MosswartMassacre/PluginCore.cs
index b12285f..bfb1b2d 100644
--- a/MosswartMassacre/PluginCore.cs
+++ b/MosswartMassacre/PluginCore.cs
@@ -13,22 +13,61 @@ namespace MosswartMassacre
public class PluginCore : PluginBase
{
internal static PluginHost MyHost;
- internal static int totalKills = 0;
- internal static int rareCount = 0;
- internal static DateTime lastKillTime = DateTime.Now;
- internal static double killsPer5Min = 0;
- internal static double killsPerHour = 0;
- internal static DateTime statsStartTime = DateTime.Now;
- internal static Timer updateTimer;
+ // Stats manager for kills and rares
+ private static StatsManager StatsMgr;
+ // Chat and command handling
+ private ChatManager chatManager;
+ private IWebSocketService _wsService;
public static bool RareMetaEnabled { get; set; } = true;
public static bool RemoteCommandsEnabled { get; set; } = false;
public static bool HttpServerEnabled { get; set; } = false;
public static string CharTag { get; set; } = "";
- public static bool TelemetryEnabled { get; set; } = false;
+ // Telemetry is removed; HTTP posting no longer supported
public bool WebSocketEnabled { get; set; } = false;
private static Queue rareMessageQueue = new Queue();
private static DateTime _lastSent = DateTime.MinValue;
private static readonly Queue _chatQueue = new Queue();
+ // Precompiled regex patterns for detecting kill messages
+ private static readonly Regex[] KillRegexes = new Regex[]
+ {
+ new Regex(@"^You flatten (?.+)'s body with the force of your assault!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^You bring (?.+) to a fiery end!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^You beat (?.+) to a lifeless pulp!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^You smite (?.+) mightily!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^You obliterate (?.+)!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^You run (?.+) through!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^You reduce (?.+) to a sizzling, oozing mass!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^You knock (?.+) into next Morningthaw!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^You split (?.+) apart!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^You cleave (?.+) in twain!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^You slay (?.+) viciously enough to impart death several times over!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^You reduce (?.+) to a drained, twisted corpse!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^Your killing blow nearly turns (?.+) inside-out!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^Your attack stops (?.+) cold!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^Your lightning coruscates over (?.+)'s mortal remains!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^Your assault sends (?.+) to an icy death!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^You killed (?.+)!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^The thunder of crushing (?.+) is followed by the deafening silence of death!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^The deadly force of your attack is so strong that (?.+)'s ancestors feel it!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+)'s seared corpse smolders before you!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+) is reduced to cinders!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+) is shattered by your assault!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+) catches your attack, with dire consequences!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+) is utterly destroyed by your attack!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+) suffers a frozen fate!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+)'s perforated corpse falls before you!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+) is fatally punctured!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+)'s last strength dissolves before you!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+) is torn to ribbons by your assault!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+) is liquified by your attack!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+)'s last strength withers before you!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+) is dessicated by your attack!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^(?.+) is incinerated by your assault!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ // Additional patterns from original killPatterns
+ new Regex(@"^(?.+)'s death is preceded by a sharp, stabbing pain!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^Electricity tears (?.+) apart!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ new Regex(@"^Blistered by lightning, (?.+) falls!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ };
protected override void Startup()
{
@@ -38,16 +77,27 @@ namespace MosswartMassacre
WriteToChat("Mosswart Massacre has started!");
- // Subscribe to chat message event
- CoreManager.Current.ChatBoxMessage += new EventHandler(OnChatText);
- CoreManager.Current.ChatBoxMessage += new EventHandler(AllChatText);
- CoreManager.Current.CommandLineText += OnChatCommand;
+ // Initialize WebSocket streaming service
+ _wsService = new WebSocketService(StatsMgr);
+ if (WebSocketEnabled)
+ {
+ _wsService.Start();
+ }
+ _wsService.OnServerCommand += HandleServerCommand;
+ // Initialize command handler for "/mm" commands
+ _commandHandler = new CommandHandler(StatsMgr, _wsService);
+
+ // Initialize chat handling
+ chatManager = new ChatManager(StatsMgr, _wsService, _commandHandler.Handle);
+ chatManager.Start();
CoreManager.Current.CharacterFilter.LoginComplete += CharacterFilter_LoginComplete;
- // Initialize the timer
- updateTimer = new Timer(1000); // Update every second
- updateTimer.Elapsed += UpdateStats;
- updateTimer.Start();
+ // Initialize stats manager
+ StatsMgr = new StatsManager();
+ StatsMgr.KillStatsUpdated += (total, per5, perHour) => MainView.UpdateKillStats(total, per5, perHour);
+ StatsMgr.ElapsedTimeUpdated += elapsed => MainView.UpdateElapsedTime(elapsed);
+ StatsMgr.RareCountUpdated += rare => MainView.UpdateRareCount(rare);
+ StatsMgr.Start();
// Initialize the view (UI)
MainView.ViewInit();
@@ -56,8 +106,6 @@ namespace MosswartMassacre
ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12;
//Enable vTank interface
vTank.Enable();
- //lyssna på commands
- WebSocket.OnServerCommand += HandleServerCommand;
}
catch (Exception ex)
{
@@ -70,31 +118,28 @@ namespace MosswartMassacre
try
{
PluginSettings.Save();
- if (TelemetryEnabled)
- Telemetry.Stop(); // ensure no dangling timer / HttpClient
WriteToChat("Mosswart Massacre is shutting down...");
+ // clean up any pending delayed commands
+ DelayedCommandManager.Shutdown();
- // Unsubscribe from chat message event
- CoreManager.Current.ChatBoxMessage -= new EventHandler(OnChatText);
- CoreManager.Current.CommandLineText -= OnChatCommand;
- CoreManager.Current.ChatBoxMessage -= new EventHandler(AllChatText);
+ // Stop chat handling
+ chatManager?.Stop();
- // Stop and dispose of the timer
- if (updateTimer != null)
- {
- updateTimer.Stop();
- updateTimer.Dispose();
- updateTimer = null;
- }
+ // Stop and dispose of stats manager
+ StatsMgr?.Stop();
+ StatsMgr?.Dispose();
// Clean up the view
MainView.ViewDestroy();
- //Disable vtank interface
+ // Disable vtank interface
vTank.Disable();
- // sluta lyssna på commands
- WebSocket.OnServerCommand -= HandleServerCommand;
- WebSocket.Stop();
-
+ // Stop WebSocket service
+ if (_wsService != null)
+ {
+ _wsService.OnServerCommand -= HandleServerCommand;
+ _wsService.Stop();
+ _wsService.Dispose();
+ }
MyHost = null;
}
catch (Exception ex)
@@ -113,240 +158,22 @@ namespace MosswartMassacre
WebSocketEnabled = PluginSettings.Instance.WebSocketEnabled;
RemoteCommandsEnabled = PluginSettings.Instance.RemoteCommandsEnabled;
HttpServerEnabled = PluginSettings.Instance.HttpServerEnabled;
- TelemetryEnabled = PluginSettings.Instance.TelemetryEnabled;
CharTag = PluginSettings.Instance.CharTag;
MainView.SetRareMetaToggleState(RareMetaEnabled);
- if (TelemetryEnabled)
- Telemetry.Start();
+ // HTTP Telemetry removed
if (WebSocketEnabled)
- WebSocket.Start();
+ _wsService?.Start();
}
- private static string NormalizeChatLine(string raw)
- {
- if (string.IsNullOrEmpty(raw))
- return raw;
-
- // 1) Remove all <…> tags
- var noTags = Regex.Replace(raw, "<[^>]+>", "");
-
- // 2) Trim trailing newline or carriage-return
- var trimmed = noTags.TrimEnd('\r', '\n');
-
- // 3) Collapse multiple spaces into one
- var collapsed = Regex.Replace(trimmed, @"[ ]{2,}", " ");
-
- return collapsed;
- }
- private void AllChatText(object sender, ChatTextInterceptEventArgs e)
- {
- try
- {
-
- var cleaned = NormalizeChatLine(e.Text);
-
- _ = WebSocket.SendChatTextAsync(e.Color, cleaned);
- }
- catch (Exception ex)
- {
- PluginCore.WriteToChat("Error sending chat over WS: " + ex.Message);
- }
- }
- private void HandleServerCommand(CommandEnvelope env)
- {
- // Skicka commands
- DispatchChatToBoxWithPluginIntercept(env.Command);
- CoreManager.Current.Actions.InvokeChatParser($"/a Executed '{env.Command}' from Mosswart Overlord");
- }
- private void OnChatText(object sender, ChatTextInterceptEventArgs e)
- {
- try
- {
- // WriteToChat($"[Debug] Chat Color: {e.Color}, Message: {e.Text}");
-
- if (IsKilledByMeMessage(e.Text))
- {
- totalKills++;
- lastKillTime = DateTime.Now;
- CalculateKillsPerInterval();
- MainView.UpdateKillStats(totalKills, killsPer5Min, killsPerHour);
- }
-
- if (IsRareDiscoveryMessage(e.Text, out string rareText))
- {
- rareCount++;
- MainView.UpdateRareCount(rareCount);
-
- if (RareMetaEnabled)
- {
- Decal_DispatchOnChatCommand("/vt setmetastate loot_rare");
- }
-
- DelayedCommandManager.AddDelayedCommand($"/a {rareText}", 3000);
- }
- // if (e.Text.EndsWith("!testrare\""))
- // {
- // string simulatedText = $"{CoreManager.Current.CharacterFilter.Name} has discovered the Ancient Pickle!";
- //
- // if (IsRareDiscoveryMessage(simulatedText, out string simulatedRareText))
- // {
- // rareCount++;
- // MainView.UpdateRareCount(rareCount);
- //
- // if (RareMetaEnabled)
- // {
- // Decal_DispatchOnChatCommand("/vt setmetastate loot_rare");
- // }
- //
- // DelayedCommandManager.AddDelayedCommand($"/a {simulatedRareText}", 3000);
- // }
- // else
- // {
- // WriteToChat("[Test] Simulated rare message didn't match the regex.");
- // }
- //
- // return;
- // }
- if (e.Color == 18 && e.Text.EndsWith("!report\""))
- {
- TimeSpan elapsed = DateTime.Now - statsStartTime;
- string reportMessage = $"Total Kills: {totalKills}, Kills per Hour: {killsPerHour:F2}, Elapsed Time: {elapsed:dd\\.hh\\:mm\\:ss}, Rares Found: {rareCount}";
- WriteToChat($"[Mosswart Massacre] Reporting to allegiance: {reportMessage}");
- MyHost.Actions.InvokeChatParser($"/a {reportMessage}");
- }
- if (RemoteCommandsEnabled && e.Color == 18)
- {
- string characterName = Regex.Escape(CoreManager.Current.CharacterFilter.Name);
- string pattern = $@"^\[Allegiance\].*Dunking Rares.*say[s]?, \""!do {characterName} (?.+)\""$";
- string tag = Regex.Escape(PluginCore.CharTag);
- string patterntag = $@"^\[Allegiance\].*Dunking Rares.*say[s]?, \""!dot {tag} (?.+)\""$";
-
-
- var match = Regex.Match(e.Text, pattern);
- var matchtag = Regex.Match(e.Text, patterntag);
-
- if (match.Success)
- {
- string command = match.Groups["command"].Value;
- DispatchChatToBoxWithPluginIntercept(command);
- DelayedCommandManager.AddDelayedCommand($"/a [Remote] Executing: {command}", 2000);
- }
- else if (matchtag.Success)
- {
- string command = matchtag.Groups["command"].Value;
- DispatchChatToBoxWithPluginIntercept(command);
- DelayedCommandManager.AddDelayedCommand($"/a [Remote] Executing: {command}", 2000);
- }
-
- }
-
-
-
-
-
-
-
-
- }
- catch (Exception ex)
- {
- WriteToChat("Error processing chat message: " + ex.Message);
- }
- }
- private void OnChatCommand(object sender, ChatParserInterceptEventArgs e)
- {
- try
- {
- if (e.Text.StartsWith("/mm", StringComparison.OrdinalIgnoreCase))
- {
- e.Eat = true; // Prevent the message from showing in chat
- HandleMmCommand(e.Text);
- }
- }
- catch (Exception ex)
- {
- PluginCore.WriteToChat($"[Error] Failed to process /mm command: {ex.Message}");
- }
- }
-
- private void UpdateStats(object sender, ElapsedEventArgs e)
- {
- try
- {
- // Update the elapsed time
- TimeSpan elapsed = DateTime.Now - statsStartTime;
- MainView.UpdateElapsedTime(elapsed);
-
- // Recalculate kill rates
- CalculateKillsPerInterval();
- MainView.UpdateKillStats(totalKills, killsPer5Min, killsPerHour);
- }
- catch (Exception ex)
- {
- WriteToChat("Error updating stats: " + ex.Message);
- }
- }
-
- private void CalculateKillsPerInterval()
- {
- double minutesElapsed = (DateTime.Now - statsStartTime).TotalMinutes;
-
- if (minutesElapsed > 0)
- {
- killsPer5Min = (totalKills / minutesElapsed) * 5;
- killsPerHour = (totalKills / minutesElapsed) * 60;
- }
- }
-
private bool IsKilledByMeMessage(string text)
{
- string[] killPatterns = new string[]
+ // Check each precompiled kill message pattern
+ foreach (var regex in KillRegexes)
{
- @"^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!$"
- };
-
- foreach (string pattern in killPatterns)
- {
- if (Regex.IsMatch(text, pattern))
+ if (regex.IsMatch(text))
return true;
}
-
return false;
}
private bool IsRareDiscoveryMessage(string text, out string rareTextOnly)
@@ -365,21 +192,20 @@ namespace MosswartMassacre
return false;
}
+ // Prefix for all chat messages from this plugin
+ private const string ChatPrefix = "[Mosswart Massacre] ";
+ ///
+ /// Write a message to the chat with plugin prefix.
+ ///
public static void WriteToChat(string message)
{
- MyHost.Actions.AddChatText("[Mosswart Massacre] " + message, 0, 1);
+ MyHost.Actions.AddChatText(ChatPrefix + message, 0, 1);
}
public static void RestartStats()
{
- totalKills = 0;
- rareCount = 0;
- statsStartTime = DateTime.Now;
- killsPer5Min = 0;
- killsPerHour = 0;
-
+ // Reset stats via StatsManager
+ StatsMgr?.Restart();
WriteToChat("Stats have been reset.");
- MainView.UpdateKillStats(totalKills, killsPer5Min, killsPerHour);
- MainView.UpdateRareCount(rareCount);
}
public static void ToggleRareMeta()
{
@@ -410,169 +236,6 @@ namespace MosswartMassacre
if (!Decal_DispatchOnChatCommand(cmd))
CoreManager.Current.Actions.InvokeChatParser(cmd);
}
- private void HandleMmCommand(string text)
- {
- // Remove the /mm prefix and trim extra whitespace
- string[] args = text.Substring(3).Trim().Split(' ');
-
- if (args.Length == 0 || string.IsNullOrEmpty(args[0]))
- {
- WriteToChat("Usage: /mm . Try /mm help");
- return;
- }
-
- string subCommand = args[0].ToLower();
-
- switch (subCommand)
- {
- case "telemetry":
- if (args.Length > 1)
- {
- if (args[1].Equals("enable", StringComparison.OrdinalIgnoreCase))
- {
- TelemetryEnabled = true;
- Telemetry.Start();
- PluginSettings.Instance.TelemetryEnabled = true;
- WriteToChat("Telemetry streaming ENABLED.");
- }
- else if (args[1].Equals("disable", StringComparison.OrdinalIgnoreCase))
- {
- TelemetryEnabled = false;
- Telemetry.Stop();
- PluginSettings.Instance.TelemetryEnabled = false;
- WriteToChat("Telemetry streaming DISABLED.");
- }
- else
- {
- WriteToChat("Usage: /mm telemetry ");
- }
- }
- else
- {
- WriteToChat("Usage: /mm telemetry ");
- }
- break;
- case "ws":
- if (args.Length > 1)
- {
- if (args[1].Equals("enable", StringComparison.OrdinalIgnoreCase))
- {
- WebSocketEnabled = true;
- WebSocket.Start();
- PluginSettings.Instance.WebSocketEnabled = true;
- WriteToChat("WS streaming ENABLED.");
- }
- else if (args[1].Equals("disable", StringComparison.OrdinalIgnoreCase))
- {
- WebSocketEnabled = false;
- WebSocket.Stop();
- PluginSettings.Instance.WebSocketEnabled = false;
- WriteToChat("WS streaming DISABLED.");
- }
- else
- {
- WriteToChat("Usage: /mm ws ");
- }
- }
- else
- {
- WriteToChat("Usage: /mm ws ");
- }
- break;
- case "help":
- WriteToChat("Mosswart Massacre Commands:");
- WriteToChat("/mm report - Show current stats");
- WriteToChat("/mm loc - Show current location");
- WriteToChat("/mm telemetry - Telemetry streaming enable|disable");
- WriteToChat("/mm ws - Websocket streaming enable|disable");
- WriteToChat("/mm reset - Reset all counters");
- WriteToChat("/mm meta - Toggle rare meta state");
- WriteToChat("/mm http - Local http-command server enable|disable");
- WriteToChat("/mm remotecommand - Listen to allegiance !do/!dot enable|disable");
- WriteToChat("/mm getmetastate - Gets the current metastate");
- break;
-
- case "report":
- TimeSpan elapsed = DateTime.Now - statsStartTime;
- string reportMessage = $"Total Kills: {totalKills}, Kills per Hour: {killsPerHour:F2}, Elapsed Time: {elapsed:dd\\.hh\\:mm\\:ss}, Rares Found: {rareCount}";
- WriteToChat(reportMessage);
- break;
- case "getmetastate":
- string metaState = VtankControl.VtGetMetaState();
- WriteToChat(metaState);
- break;
-
- case "loc":
- Coordinates here = Coordinates.Me;
- var pos = Utils.GetPlayerPosition();
- WriteToChat($"Location: {here} (X={pos.X:F1}, Y={pos.Y:F1}, Z={pos.Z:F1})");
- break;
- case "reset":
- RestartStats();
- break;
- case "meta":
- RareMetaEnabled = !RareMetaEnabled;
- WriteToChat($"Rare meta state is now {(RareMetaEnabled ? "ON" : "OFF")}");
- MainView.SetRareMetaToggleState(RareMetaEnabled); // <-- sync the UI
- break;
-
- case "http":
- if (args.Length > 1)
- {
- if (args[1].Equals("enable", StringComparison.OrdinalIgnoreCase))
- {
- PluginSettings.Instance.HttpServerEnabled = true;
- HttpServerEnabled = true;
- HttpCommandServer.Start();
- }
- else if (args[1].Equals("disable", StringComparison.OrdinalIgnoreCase))
- {
- PluginSettings.Instance.HttpServerEnabled = false;
- HttpServerEnabled = false;
- HttpCommandServer.Stop();
- }
- else
- {
- WriteToChat("Usage: /mm http ");
- }
- }
- else
- {
- WriteToChat("Usage: /mm http ");
- }
- break;
-
- case "remotecommands":
- if (args.Length > 1)
- {
- if (args[1].Equals("enable", StringComparison.OrdinalIgnoreCase))
- {
- PluginSettings.Instance.RemoteCommandsEnabled = true;
- RemoteCommandsEnabled = true;
- WriteToChat("Remote command listening is now ENABLED.");
- }
- else if (args[1].Equals("disable", StringComparison.OrdinalIgnoreCase))
- {
- PluginSettings.Instance.RemoteCommandsEnabled = false;
- RemoteCommandsEnabled = false;
- WriteToChat("Remote command listening is now DISABLED.");
- }
- else
- {
- WriteToChat("Invalid remotecommands argument. Use 'enable' or 'disable'.");
- }
- }
- else
- {
- WriteToChat("Usage: /mm remotecommands ");
- }
- break;
-
- default:
- WriteToChat($"Unknown /mm command: {subCommand}. Try /mm help");
- break;
- }
- }
}
diff --git a/MosswartMassacre/PluginSettings.cs b/MosswartMassacre/PluginSettings.cs
index 37d1826..9b2f726 100644
--- a/MosswartMassacre/PluginSettings.cs
+++ b/MosswartMassacre/PluginSettings.cs
@@ -11,6 +11,15 @@ namespace MosswartMassacre
private static PluginSettings _instance;
private static string _filePath;
private static readonly object _sync = new object();
+ // Timer to debounce saving settings to disk
+ private static readonly System.Timers.Timer _saveTimer;
+ // Reuse YAML serializer/deserializer to avoid rebuilding per operation
+ private static readonly IDeserializer _deserializer = new DeserializerBuilder()
+ .WithNamingConvention(UnderscoredNamingConvention.Instance)
+ .Build();
+ private static readonly ISerializer _serializer = new SerializerBuilder()
+ .WithNamingConvention(UnderscoredNamingConvention.Instance)
+ .Build();
// backing fields
private bool _remoteCommandsEnabled = false;
@@ -23,6 +32,12 @@ namespace MosswartMassacre
public static PluginSettings Instance => _instance
?? throw new InvalidOperationException("PluginSettings not initialized");
+ // Static constructor to initialize save debounce timer
+ static PluginSettings()
+ {
+ _saveTimer = new System.Timers.Timer(1000) { AutoReset = false };
+ _saveTimer.Elapsed += (s, e) => Save();
+ }
public static void Initialize()
{
// determine settings file path
@@ -30,10 +45,7 @@ namespace MosswartMassacre
string pluginFolder = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location);
_filePath = Path.Combine(pluginFolder, $"{characterName}.yaml");
- // build serializer/deserializer once
- var builder = new DeserializerBuilder()
- .WithNamingConvention(UnderscoredNamingConvention.Instance);
- var deserializer = builder.Build();
+ // use shared deserializer
PluginSettings loaded = null;
@@ -42,7 +54,7 @@ namespace MosswartMassacre
try
{
string yaml = File.ReadAllText(_filePath);
- loaded = deserializer.Deserialize(yaml);
+ loaded = _deserializer.Deserialize(yaml);
}
catch (Exception ex)
{
@@ -70,11 +82,8 @@ namespace MosswartMassacre
{
try
{
- // serialize to YAML
- var serializer = new SerializerBuilder()
- .WithNamingConvention(UnderscoredNamingConvention.Instance)
- .Build();
- var yaml = serializer.Serialize(_instance);
+ // serialize to YAML using shared serializer
+ var yaml = _serializer.Serialize(_instance);
// write temp file
var temp = _filePath + ".tmp";
@@ -99,40 +108,43 @@ namespace MosswartMassacre
}
}
}
+ ///
+ /// Schedule settings to be saved after debounce interval.
+ ///
+ private static void ScheduleSave()
+ {
+ _saveTimer.Stop();
+ _saveTimer.Start();
+ }
// public properties
public bool RemoteCommandsEnabled
{
get => _remoteCommandsEnabled;
- set { _remoteCommandsEnabled = value; Save(); }
+ set { _remoteCommandsEnabled = value; ScheduleSave(); }
}
public bool RareMetaEnabled
{
get => _rareMetaEnabled;
- set { _rareMetaEnabled = value; Save(); }
+ set { _rareMetaEnabled = value; ScheduleSave(); }
}
public bool HttpServerEnabled
{
get => _httpServerEnabled;
- set { _httpServerEnabled = value; Save(); }
+ set { _httpServerEnabled = value; ScheduleSave(); }
}
- public bool TelemetryEnabled
- {
- get => _telemetryEnabled;
- set { _telemetryEnabled = value; Save(); }
- }
public bool WebSocketEnabled
{
get => _webSocketEnabled;
- set { _webSocketEnabled = value; Save(); }
+ set { _webSocketEnabled = value; ScheduleSave(); }
}
public string CharTag
{
get => _charTag;
- set { _charTag = value; Save(); }
+ set { _charTag = value; ScheduleSave(); }
}
}
}
diff --git a/MosswartMassacre/StatsManager.cs b/MosswartMassacre/StatsManager.cs
new file mode 100644
index 0000000..3f646b6
--- /dev/null
+++ b/MosswartMassacre/StatsManager.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Timers;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Manages kill and rare statistics, with periodic updates.
+ ///
+ public class StatsManager : IDisposable
+ {
+ private readonly Timer _timer;
+
+ public int TotalKills { get; private set; }
+ public int RareCount { get; private set; }
+ public double KillsPer5Min { get; private set; }
+ public double KillsPerHour { get; private set; }
+ public DateTime StatsStartTime { get; private set; }
+ public DateTime LastKillTime { get; private set; }
+
+ ///
+ /// Raised when kill statistics change: total kills, kills per 5 min, kills per hour.
+ ///
+ public event Action KillStatsUpdated;
+ ///
+ /// Raised when elapsed time updates (periodic timer).
+ ///
+ public event Action ElapsedTimeUpdated;
+ ///
+ /// Raised when rare count changes.
+ ///
+ public event Action RareCountUpdated;
+
+ public StatsManager()
+ {
+ StatsStartTime = DateTime.Now;
+ LastKillTime = DateTime.Now;
+ _timer = new Timer(1000);
+ _timer.Elapsed += OnTimerElapsed;
+ }
+
+ ///
+ /// Start periodic updates.
+ ///
+ public void Start()
+ {
+ _timer.Start();
+ }
+
+ ///
+ /// Stop periodic updates.
+ ///
+ public void Stop()
+ {
+ _timer.Stop();
+ }
+
+ private void OnTimerElapsed(object sender, ElapsedEventArgs e)
+ {
+ RecalculateRates();
+ ElapsedTimeUpdated?.Invoke(DateTime.Now - StatsStartTime);
+ KillStatsUpdated?.Invoke(TotalKills, KillsPer5Min, KillsPerHour);
+ }
+
+ ///
+ /// Register a new kill, updating counts and raising events.
+ ///
+ public void RegisterKill()
+ {
+ TotalKills++;
+ LastKillTime = DateTime.Now;
+ RecalculateRates();
+ KillStatsUpdated?.Invoke(TotalKills, KillsPer5Min, KillsPerHour);
+ }
+
+ ///
+ /// Register a rare discovery and raise event.
+ ///
+ public void RegisterRare()
+ {
+ RareCount++;
+ RareCountUpdated?.Invoke(RareCount);
+ }
+
+ ///
+ /// Reset all statistics to zero and starting point.
+ ///
+ public void Restart()
+ {
+ TotalKills = 0;
+ RareCount = 0;
+ StatsStartTime = DateTime.Now;
+ KillsPer5Min = 0;
+ KillsPerHour = 0;
+ LastKillTime = DateTime.Now;
+ KillStatsUpdated?.Invoke(TotalKills, KillsPer5Min, KillsPerHour);
+ RareCountUpdated?.Invoke(RareCount);
+ }
+
+ private void RecalculateRates()
+ {
+ double minutesElapsed = (DateTime.Now - StatsStartTime).TotalMinutes;
+ if (minutesElapsed > 0)
+ {
+ KillsPer5Min = (TotalKills / minutesElapsed) * 5;
+ KillsPerHour = (TotalKills / minutesElapsed) * 60;
+ }
+ else
+ {
+ KillsPer5Min = 0;
+ KillsPerHour = 0;
+ }
+ }
+
+ public void Dispose()
+ {
+ _timer.Elapsed -= OnTimerElapsed;
+ _timer.Dispose();
+ }
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/Telemetry.cs b/MosswartMassacre/Telemetry.cs
deleted file mode 100644
index ce9bb79..0000000
--- a/MosswartMassacre/Telemetry.cs
+++ /dev/null
@@ -1,109 +0,0 @@
-// Telemetry.cs ───────────────────────────────────────────────────────────────
-using System;
-using System.Net.Http;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using Decal.Adapter;
-using Newtonsoft.Json;
-
-namespace MosswartMassacre
-{
- public static class Telemetry
- {
- /* ───────────── configuration ───────────── */
- private const string Endpoint = "https://mosswart.snakedesert.se/position/"; // <- trailing slash!
- private const string SharedSecret = "your_shared_secret"; // <- keep in sync
- private const int IntervalSec = 5; // seconds between posts
-
- /* ───────────── runtime state ───────────── */
- private static readonly HttpClient _http = new HttpClient();
- private static string _sessionId;
- private static CancellationTokenSource _cts;
- private static bool _enabled;
-
- /* ───────────── public API ───────────── */
- public static void Start()
- {
- if (_enabled) return;
-
- _enabled = true;
- _sessionId = $"{CoreManager.Current.CharacterFilter.Name}-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
- _cts = new CancellationTokenSource();
-
- PluginCore.WriteToChat("[Telemetry] HTTP streaming ENABLED");
-
- _ = Task.Run(() => LoopAsync(_cts.Token)); // fire-and-forget
- }
-
- public static void Stop()
- {
- if (!_enabled) return;
- _cts.Cancel();
- _enabled = false;
- PluginCore.WriteToChat("[Telemetry] HTTP streaming DISABLED");
- }
-
- /* ───────────── async loop ───────────── */
- private static async Task LoopAsync(CancellationToken token)
- {
- while (!token.IsCancellationRequested)
- {
- try
- {
- await SendSnapshotAsync(token);
- }
- catch (Exception ex)
- {
- PluginCore.WriteToChat($"[Telemetry] send failed: {ex.Message}");
- }
-
- try
- {
- await Task.Delay(TimeSpan.FromSeconds(IntervalSec), token);
- }
- catch (TaskCanceledException) { } // expected on Stop()
- }
- }
-
- /* ───────────── single POST ───────────── */
- private static async Task SendSnapshotAsync(CancellationToken token)
- {
- var coords = Coordinates.Me;
-
- var payload = new
- {
- character_name = CoreManager.Current.CharacterFilter.Name,
- char_tag = PluginCore.CharTag,
- session_id = _sessionId,
- timestamp = DateTime.UtcNow.ToString("o"),
-
- ew = coords.EW,
- ns = coords.NS,
- z = coords.Z,
-
- kills = PluginCore.totalKills,
- onlinetime = (DateTime.Now - PluginCore.statsStartTime).ToString(@"dd\.hh\:mm\:ss"),
- kills_per_hour = PluginCore.killsPerHour.ToString("F0"),
- deaths = 0,
- rares_found = PluginCore.rareCount,
- prismatic_taper_count = 0,
- vt_state = VtankControl.VtGetMetaState(),
- };
-
- string json = JsonConvert.SerializeObject(payload);
- var req = new HttpRequestMessage(HttpMethod.Post, Endpoint)
- {
- Content = new StringContent(json, Encoding.UTF8, "application/json")
- };
- req.Headers.Add("X-Plugin-Secret", SharedSecret);
-
- using var resp = await _http.SendAsync(req, token);
-
- if (!resp.IsSuccessStatusCode) // stay quiet on success
- {
- PluginCore.WriteToChat($"[Telemetry] server replied {resp.StatusCode}");
- }
- }
- }
-}
diff --git a/MosswartMassacre/WebSocket.cs b/MosswartMassacre/WebSocket.cs
deleted file mode 100644
index fd641cf..0000000
--- a/MosswartMassacre/WebSocket.cs
+++ /dev/null
@@ -1,255 +0,0 @@
-// WebSocket.cs
-using System;
-using System.Net.WebSockets;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using Decal.Adapter;
-using Newtonsoft.Json;
-
-namespace MosswartMassacre
-{
- // 1) The envelope type for incoming commands
- public class CommandEnvelope
- {
- [JsonProperty("player_name")]
- public string PlayerName { get; set; }
-
- [JsonProperty("command")]
- public string Command { get; set; }
- }
-
- public static class WebSocket
- {
- // ─── configuration ──────────────────────────
- private static readonly Uri WsEndpoint = new Uri("wss://mosswart.snakedesert.se/websocket/");
- private const string SharedSecret = "your_shared_secret";
- private const int IntervalSec = 5;
- private static string SessionId = "";
-
- // ─── runtime state ──────────────────────────
- private static ClientWebSocket _ws;
- private static CancellationTokenSource _cts;
- private static bool _enabled;
- private static readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
-
- ///
- /// Fires when a valid CommandEnvelope arrives for this character.
- ///
- public static event Action OnServerCommand;
-
- // ─── public API ─────────────────────────────
-
- public static void Start()
- {
- if (_enabled) return;
- _enabled = true;
- _cts = new CancellationTokenSource();
-
- PluginCore.WriteToChat("[WebSocket] connecting…");
- _ = Task.Run(ConnectAndLoopAsync);
-
- }
-
- public static void Stop()
- {
- if (!_enabled) return;
- _enabled = false;
-
- _cts.Cancel();
- _ws?.Abort();
- _ws?.Dispose();
- _ws = null;
-
- PluginCore.WriteToChat("[WebSocket] DISABLED");
- }
-
- // ─── connect / receive / telemetry loop ──────────────────────
-
- private static async Task ConnectAndLoopAsync()
- {
- while (_enabled && !_cts.IsCancellationRequested)
- {
- try
- {
- // 1) Establish connection
- _ws = new ClientWebSocket();
- _ws.Options.SetRequestHeader("X-Plugin-Secret", SharedSecret);
- await _ws.ConnectAsync(WsEndpoint, _cts.Token);
- PluginCore.WriteToChat("[WebSocket] CONNECTED");
- SessionId = $"{CoreManager.Current.CharacterFilter.Name}-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
-
- // ─── Register this socket under our character name ───
- var registerEnvelope = new
- {
- type = "register",
- player_name = CoreManager.Current.CharacterFilter.Name
- };
- var regJson = JsonConvert.SerializeObject(registerEnvelope);
- await SendEncodedAsync(regJson, _cts.Token);
- PluginCore.WriteToChat("[WebSocket] REGISTERED");
-
- var buffer = new byte[4096];
-
- // 2) Fire-and-forget receive loop
- var receiveTask = Task.Run(async () =>
- {
- while (_ws.State == WebSocketState.Open && !_cts.Token.IsCancellationRequested)
- {
- WebSocketReceiveResult result;
- try
- {
- result = await _ws.ReceiveAsync(new ArraySegment(buffer), _cts.Token);
- }
- catch (OperationCanceledException)
- {
- break;
- }
- catch (Exception ex)
- {
- PluginCore.WriteToChat($"[WebSocket] receive error: {ex.Message}");
- break;
- }
-
- if (result.MessageType == WebSocketMessageType.Close)
- break;
-
- var msg = Encoding.UTF8.GetString(buffer, 0, result.Count).Trim();
-
- // 3) Parse into CommandEnvelope
- CommandEnvelope env;
- try
- {
- env = JsonConvert.DeserializeObject(msg);
- }
- catch (JsonException)
- {
- continue; // skip malformed JSON
- }
-
- // 4) Filter by this character name
- if (string.Equals(
- env.PlayerName,
- CoreManager.Current.CharacterFilter.Name,
- StringComparison.OrdinalIgnoreCase))
- {
- OnServerCommand?.Invoke(env);
- }
- }
- });
-
- // 5) Inline telemetry loop
- while (_ws.State == WebSocketState.Open && !_cts.Token.IsCancellationRequested)
- {
- var json = BuildPayloadJson();
- await SendEncodedAsync(json, _cts.Token);
-
- try
- {
- await Task.Delay(TimeSpan.FromSeconds(IntervalSec), _cts.Token);
- }
- catch (OperationCanceledException)
- {
- break;
- }
- }
-
- // Wait for receive loop to finish
- await receiveTask;
- }
- catch (OperationCanceledException)
- {
- break;
- }
- catch (Exception ex)
- {
- PluginCore.WriteToChat($"[WebSocket] error: {ex.Message}");
- }
- finally
- {
- _ws?.Abort();
- _ws?.Dispose();
- _ws = null;
- }
-
- // Pause before reconnecting
- try { await Task.Delay(2000, CancellationToken.None); } catch { }
- }
- }
-
-
- // ─── fire-and-forget chat sender ────────────────────
-
- public static async Task SendChatTextAsync(int colorIndex, string chatText)
- {
- var envelope = new
- {
- type = "chat",
- character_name = CoreManager.Current.CharacterFilter.Name,
- text = chatText,
- color = colorIndex
-
- };
- var json = JsonConvert.SerializeObject(envelope);
- await SendEncodedAsync(json, CancellationToken.None);
- }
-
- // ─── shared send helper with locking ───────────────
-
- private static async Task SendEncodedAsync(string text, CancellationToken token)
- {
- await _sendLock.WaitAsync(token);
- try
- {
- if (_ws == null || _ws.State != WebSocketState.Open)
- return;
-
- var bytes = Encoding.UTF8.GetBytes(text);
- await _ws.SendAsync(new ArraySegment(bytes),
- WebSocketMessageType.Text,
- true,
- token);
- }
- catch (Exception ex)
- {
- PluginCore.WriteToChat("[WebSocket] send error: " + ex.Message);
- _ws?.Abort();
- _ws?.Dispose();
- _ws = null;
- }
- finally
- {
- _sendLock.Release();
- }
- }
-
- // ─── payload builder ──────────────────────────────
-
-
-
- private static string BuildPayloadJson()
- {
- var coords = Coordinates.Me;
- var payload = new
- {
- type = "telemetry",
- character_name = CoreManager.Current.CharacterFilter.Name,
- char_tag = PluginCore.CharTag,
- session_id = SessionId,
- timestamp = DateTime.UtcNow.ToString("o"),
- ew = coords.EW,
- ns = coords.NS,
- z = coords.Z,
- kills = PluginCore.totalKills,
- onlinetime = (DateTime.Now - PluginCore.statsStartTime)
- .ToString(@"dd\.hh\:mm\:ss"),
- kills_per_hour = PluginCore.killsPerHour.ToString("F0"),
- deaths = 0,
- rares_found = PluginCore.rareCount,
- prismatic_taper_count = 0,
- vt_state = VtankControl.VtGetMetaState()
- };
- return JsonConvert.SerializeObject(payload);
- }
- }
-}
diff --git a/MosswartMassacre/WebSocketService.cs b/MosswartMassacre/WebSocketService.cs
new file mode 100644
index 0000000..9e8ddd5
--- /dev/null
+++ b/MosswartMassacre/WebSocketService.cs
@@ -0,0 +1,215 @@
+using System;
+using System.Net.WebSockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Configuration;
+using Decal.Adapter;
+using Newtonsoft.Json;
+
+namespace MosswartMassacre
+{
+ ///
+ /// Envelope for commands received via WebSocket.
+ ///
+ public class CommandEnvelope
+ {
+ [JsonProperty("player_name")]
+ public string PlayerName { get; set; }
+
+ [JsonProperty("command")]
+ public string Command { get; set; }
+ }
+
+ ///
+ /// WebSocket service for sending chat and receiving server commands.
+ ///
+ public class WebSocketService : IWebSocketService
+ {
+ private readonly StatsManager _statsManager;
+ private readonly Uri _endpoint;
+ private readonly string _secret;
+ private readonly int _intervalSec;
+ private ClientWebSocket _ws;
+ private CancellationTokenSource _cts;
+ private bool _enabled;
+ private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
+ private string _sessionId;
+
+ ///
+ /// Raised when a command arrives for this character.
+ ///
+ public event Action OnServerCommand;
+
+ public WebSocketService(StatsManager statsManager)
+ {
+ _statsManager = statsManager ?? throw new ArgumentNullException(nameof(statsManager));
+ // Load configuration
+ try
+ {
+ var endpoint = ConfigurationManager.AppSettings["WebSocketEndpoint"];
+ _endpoint = !string.IsNullOrEmpty(endpoint)
+ ? new Uri(endpoint)
+ : new Uri("wss://mosswart.snakedesert.se/websocket/");
+ }
+ catch
+ {
+ _endpoint = new Uri("wss://mosswart.snakedesert.se/websocket/");
+ }
+ _secret = ConfigurationManager.AppSettings["WebSocketSharedSecret"]
+ ?? "your_shared_secret";
+ if (!int.TryParse(ConfigurationManager.AppSettings["WebSocketIntervalSec"], out _intervalSec))
+ {
+ _intervalSec = 5;
+ }
+ }
+
+ public void Start()
+ {
+ if (_enabled) return;
+ _enabled = true;
+ _cts = new CancellationTokenSource();
+ PluginCore.WriteToChat("[WebSocket] connecting…");
+ _ = Task.Run(() => ConnectLoopAsync(_cts.Token));
+ }
+
+ public void Stop()
+ {
+ if (!_enabled) return;
+ _enabled = false;
+ _cts.Cancel();
+ _ws?.Abort();
+ _ws?.Dispose();
+ _ws = null;
+ PluginCore.WriteToChat("[WebSocket] DISABLED");
+ }
+
+ public async Task SendChatTextAsync(int colorIndex, string chatText)
+ {
+ await _sendLock.WaitAsync();
+ try
+ {
+ if (_ws == null || _ws.State != WebSocketState.Open)
+ return;
+ var envelope = new
+ {
+ type = "chat",
+ character_name = CoreManager.Current.CharacterFilter.Name,
+ text = chatText,
+ color = colorIndex
+ };
+ var json = JsonConvert.SerializeObject(envelope);
+ var bytes = Encoding.UTF8.GetBytes(json);
+ await _ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, _cts.Token);
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat("[WebSocket] send error: " + ex.Message);
+ Stop();
+ }
+ finally
+ {
+ _sendLock.Release();
+ }
+ }
+
+ private async Task ConnectLoopAsync(CancellationToken token)
+ {
+ while (_enabled && !token.IsCancellationRequested)
+ {
+ try
+ {
+ _ws = new ClientWebSocket();
+ _ws.Options.SetRequestHeader("X-Plugin-Secret", _secret);
+ await _ws.ConnectAsync(_endpoint, token);
+ PluginCore.WriteToChat("[WebSocket] CONNECTED");
+ _sessionId = $"{CoreManager.Current.CharacterFilter.Name}-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
+
+ // Register this client
+ var registerMsg = new { type = "register", player_name = CoreManager.Current.CharacterFilter.Name };
+ await SendChatTextAsync(0, JsonConvert.SerializeObject(registerMsg));
+ PluginCore.WriteToChat("[WebSocket] REGISTERED");
+
+ var buffer = new byte[4096];
+ var receiveTask = ReceiveLoopAsync(buffer, token);
+
+ // Telemetry loop
+ while (_ws.State == WebSocketState.Open && !token.IsCancellationRequested)
+ {
+ var coords = Coordinates.Me;
+ var payload = new
+ {
+ type = "telemetry",
+ character_name = CoreManager.Current.CharacterFilter.Name,
+ char_tag = PluginCore.CharTag,
+ session_id = _sessionId,
+ timestamp = DateTime.UtcNow.ToString("o"),
+ ew = coords.EW,
+ ns = coords.NS,
+ z = coords.Z,
+ kills = _statsManager.TotalKills,
+ onlinetime = (DateTime.Now - _statsManager.StatsStartTime).ToString(@"dd\.hh\:mm\:ss"),
+ kills_per_hour = _statsManager.KillsPerHour,
+ deaths = 0,
+ rares_found = _statsManager.RareCount,
+ prismatic_taper_count = 0,
+ vt_state = VtankControl.VtGetMetaState()
+ };
+ var json = JsonConvert.SerializeObject(payload);
+ var bytes = Encoding.UTF8.GetBytes(json);
+ await _ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, token);
+ await Task.Delay(TimeSpan.FromSeconds(_intervalSec), token);
+ }
+
+ await receiveTask;
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[WebSocket] error: {ex.Message}");
+ }
+ finally
+ {
+ _ws?.Abort();
+ _ws?.Dispose();
+ _ws = null;
+ }
+ // wait before reconnect
+ await Task.Delay(2000);
+ }
+ }
+
+ private async Task ReceiveLoopAsync(byte[] buffer, CancellationToken token)
+ {
+ while (_ws.State == WebSocketState.Open && !token.IsCancellationRequested)
+ {
+ try
+ {
+ var result = await _ws.ReceiveAsync(new ArraySegment(buffer), token);
+ if (result.MessageType == WebSocketMessageType.Close) break;
+ var msg = Encoding.UTF8.GetString(buffer, 0, result.Count).Trim();
+ CommandEnvelope env;
+ try { env = JsonConvert.DeserializeObject(msg); }
+ catch { continue; }
+ if (string.Equals(env.PlayerName, CoreManager.Current.CharacterFilter.Name, StringComparison.OrdinalIgnoreCase))
+ OnServerCommand?.Invoke(env);
+ }
+ catch (OperationCanceledException) { break; }
+ catch (Exception ex)
+ {
+ PluginCore.WriteToChat($"[WebSocket] receive error: {ex.Message}");
+ break;
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ Stop();
+ _sendLock.Dispose();
+ }
+ }
+}
\ No newline at end of file
diff --git a/MosswartMassacre/app.config b/MosswartMassacre/app.config
index 7237d3b..db4c43a 100644
--- a/MosswartMassacre/app.config
+++ b/MosswartMassacre/app.config
@@ -1,5 +1,11 @@
+
+
+
+
+
+