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..caaabc1 --- /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(new[] { ' ' }, 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 ac9acd5..a518e54 100644 --- a/MosswartMassacre/MosswartMassacre.csproj +++ b/MosswartMassacre/MosswartMassacre.csproj @@ -48,6 +48,9 @@ False lib\Decal.Interop.Inject.dll + + $(FrameworkPathOverride)\System.Configuration.dll + ..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll @@ -76,7 +79,6 @@ - @@ -92,6 +94,14 @@ Resources.resx + + + + + + + + diff --git a/MosswartMassacre/PluginCore.cs b/MosswartMassacre/PluginCore.cs index 95e1858..2b5756e 100644 --- a/MosswartMassacre/PluginCore.cs +++ b/MosswartMassacre/PluginCore.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Net; using System.Runtime.InteropServices; @@ -9,25 +9,76 @@ using Decal.Adapter.Wrappers; namespace MosswartMassacre { + [ComVisible(true)] + [Guid("be0a51b6-cf1f-4318-ad49-e40d6aebe14b")] [FriendlyName("Mosswart Massacre")] 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; + private ICommandHandler _commandHandler; public static bool RareMetaEnabled { get; set; } = true; + /// + /// Handles server-sent commands via WebSocket. + /// + private void HandleServerCommand(CommandEnvelope env) + { + DispatchChatToBoxWithPluginIntercept(env.Command); + CoreManager.Current.Actions.InvokeChatParser($"/a Executed '{env.Command}' from Mosswart Overlord"); + } 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() { @@ -37,15 +88,27 @@ namespace MosswartMassacre WriteToChat("Mosswart Massacre has started!"); - // Subscribe to chat message event - CoreManager.Current.ChatBoxMessage += new EventHandler(OnChatText); - 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(); @@ -66,27 +129,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; + // 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(); - + // Stop WebSocket service + if (_wsService != null) + { + _wsService.OnServerCommand -= HandleServerCommand; + _wsService.Stop(); + _wsService.Dispose(); + } MyHost = null; } catch (Exception ex) @@ -102,209 +166,34 @@ namespace MosswartMassacre // Apply the values RareMetaEnabled = PluginSettings.Instance.RareMetaEnabled; + 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) + _wsService?.Start(); } - - private void OnChatText(object sender, ChatTextInterceptEventArgs e) + /// + /// Determines if the chat text indicates a kill by this character. + /// + public static bool IsKilledByMeMessage(string text) { - try + // Check each precompiled kill message pattern + foreach (var regex in KillRegexes) { - // 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[] - { - @"^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) + /// + /// Determines if the chat text indicates a rare discovery by this character. + /// + public static bool IsRareDiscoveryMessage(string text, out string rareTextOnly) { rareTextOnly = null; @@ -320,21 +209,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() { @@ -365,141 +253,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 "help": - WriteToChat("Mosswart Massacre Commands:"); - WriteToChat("/mm report - Show current stats"); - WriteToChat("/mm loc - Show current location"); - WriteToChat("/mm telemetry - Telemetry streaming enable|disable"); // NEW - 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 e59f09e..61229ba 100644 --- a/MosswartMassacre/PluginSettings.cs +++ b/MosswartMassacre/PluginSettings.cs @@ -11,17 +11,32 @@ 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; private bool _rareMetaEnabled = true; private bool _httpServerEnabled = false; - private bool _telemetryEnabled = false; + private bool _webSocketEnabled = false; private string _charTag = "default"; 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 @@ -29,10 +44,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; @@ -41,7 +53,7 @@ namespace MosswartMassacre try { string yaml = File.ReadAllText(_filePath); - loaded = deserializer.Deserialize(yaml); + loaded = _deserializer.Deserialize(yaml); } catch (Exception ex) { @@ -69,11 +81,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"; @@ -98,36 +107,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 + public bool WebSocketEnabled { - get => _telemetryEnabled; - set { _telemetryEnabled = value; Save(); } + get => _webSocketEnabled; + set { _webSocketEnabled = value; ScheduleSave(); } } - public string CharTag { get => _charTag; - set { _charTag = value; Save(); } + set { _charTag = value; ScheduleSave(); } } } } diff --git a/MosswartMassacre/Properties/AssemblyInfo.cs b/MosswartMassacre/Properties/AssemblyInfo.cs index 744091c..ffef9d2 100644 --- a/MosswartMassacre/Properties/AssemblyInfo.cs +++ b/MosswartMassacre/Properties/AssemblyInfo.cs @@ -19,7 +19,7 @@ using System.Runtime.InteropServices; [assembly: ComVisible(false)] // The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("9b6a07e1-ae78-47f4-b09c-174f6a27d7a3")] +[assembly: Guid("be0a51b6-cf1f-4318-ad49-e40d6aebe14b")] // Version information for an assembly consists of the following four values: // Major Version 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/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 @@  + + + + + + diff --git a/MosswartMassacre/lib/utank2-i.dll b/MosswartMassacre/lib/utank2-i.dll new file mode 100644 index 0000000..7696f05 Binary files /dev/null and b/MosswartMassacre/lib/utank2-i.dll differ