From 60d06341dd7ac8a257bc720dbc16a0b75adfddbb Mon Sep 17 00:00:00 2001 From: erik Date: Thu, 8 May 2025 19:12:39 +0000 Subject: [PATCH] majorcleaning --- MosswartMassacre/ChatManager.cs | 140 ++++++ MosswartMassacre/CommandHandler.cs | 137 ++++++ MosswartMassacre/DelayedCommandManager.cs | 14 +- MosswartMassacre/ICommandHandler.cs | 14 + MosswartMassacre/IStreamService.cs | 18 + MosswartMassacre/IWebSocketService.cs | 23 + MosswartMassacre/MosswartMassacre.csproj | 10 +- MosswartMassacre/PluginCore.cs | 525 ++++------------------ MosswartMassacre/PluginSettings.cs | 52 ++- MosswartMassacre/StatsManager.cs | 120 +++++ MosswartMassacre/Telemetry.cs | 109 ----- MosswartMassacre/WebSocket.cs | 255 ----------- MosswartMassacre/WebSocketService.cs | 215 +++++++++ MosswartMassacre/app.config | 6 + 14 files changed, 820 insertions(+), 818 deletions(-) create mode 100644 MosswartMassacre/ChatManager.cs create mode 100644 MosswartMassacre/CommandHandler.cs create mode 100644 MosswartMassacre/ICommandHandler.cs create mode 100644 MosswartMassacre/IStreamService.cs create mode 100644 MosswartMassacre/IWebSocketService.cs create mode 100644 MosswartMassacre/StatsManager.cs delete mode 100644 MosswartMassacre/Telemetry.cs delete mode 100644 MosswartMassacre/WebSocket.cs create mode 100644 MosswartMassacre/WebSocketService.cs 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 @@  + + + + + +