Compare commits

..

7 commits

Author SHA1 Message Date
erik
61c74be07e more guuids 2025-05-09 07:01:28 +00:00
erik
aa744a8edc fixed guid 2025-05-09 06:55:01 +00:00
erik
aede9d8bdd minor fix 2025-05-08 19:39:29 +00:00
erik
4e2bb68e86 minor fix 2025-05-08 19:35:49 +00:00
erik
2e12766f8b minor fix 2025-05-08 19:26:49 +00:00
erik
60d06341dd majorcleaning 2025-05-08 19:12:39 +00:00
erik
d2e9988bdd Websockets-version 2025-05-05 20:08:15 +02:00
15 changed files with 850 additions and 495 deletions

View file

@ -0,0 +1,140 @@
using System;
using System.Text.RegularExpressions;
using Decal.Adapter;
namespace MosswartMassacre
{
/// <summary>
/// Handles game chat events: kill/rare detection, forwarding chat to WebSocket, and "/mm" command parsing.
/// </summary>
internal class ChatManager : IDisposable
{
private readonly StatsManager _statsManager;
private readonly IWebSocketService _wsService;
private readonly Action<string> _mmCommandHandler;
private bool _isStarted;
/// <summary>
/// Constructs a new ChatManager.
/// </summary>
/// <param name="statsManager">Stats manager for registering kills/rares.</param>
/// <param name="mmCommandHandler">Delegate to handle '/mm' commands.</param>
public ChatManager(StatsManager statsManager, IWebSocketService wsService, Action<string> mmCommandHandler)
{
_statsManager = statsManager ?? throw new ArgumentNullException(nameof(statsManager));
_wsService = wsService ?? throw new ArgumentNullException(nameof(wsService));
_mmCommandHandler = mmCommandHandler;
}
/// <summary>
/// Subscribes to chat and command events.
/// </summary>
public void Start()
{
if (_isStarted) return;
_isStarted = true;
CoreManager.Current.ChatBoxMessage += OnChatIntercept;
CoreManager.Current.CommandLineText += OnChatCommand;
}
/// <summary>
/// Unsubscribes from chat and command events.
/// </summary>
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} (?<command>.+)\\\"$";
string tag = Regex.Escape(PluginCore.CharTag);
string patterntag = $"^\\[Allegiance\\].*Dunking Rares.*say[s]?, \\\"!dot {tag} (?<command>.+)\\\"$";
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();
}
}
}

View file

@ -0,0 +1,137 @@
using System;
using System.Text.RegularExpressions;
using Decal.Adapter;
namespace MosswartMassacre
{
/// <summary>
/// Handles "/mm" commands from chat.
/// </summary>
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<string>();
if (args.Length == 0)
{
PluginCore.WriteToChat("Usage: /mm <command>. 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 <enable|disable>");
}
}
else
{
PluginCore.WriteToChat("Usage: /mm ws <enable|disable>");
}
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 <enable|disable>");
}
}
else
{
PluginCore.WriteToChat("Usage: /mm http <enable|disable>");
}
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;
}
}
}
}

View file

@ -44,6 +44,18 @@ namespace MosswartMassacre
PluginCore.WriteToChat("Error in delayed command system: " + ex.Message);
}
}
/// <summary>
/// Shutdown the delayed command system: unsubscribe from RenderFrame and clear pending commands.
/// </summary>
public static void Shutdown()
{
if (isDelayListening)
{
CoreManager.Current.RenderFrame -= Core_RenderFrame_Delay;
isDelayListening = false;
}
delayedCommands.Clear();
}
}
public class DelayedCommand

View file

@ -0,0 +1,14 @@
namespace MosswartMassacre
{
/// <summary>
/// Handles plugin commands (e.g., /mm commands).
/// </summary>
public interface ICommandHandler
{
/// <summary>
/// Processes a command text, e.g. "/mm report".
/// </summary>
/// <param name="commandText">The full command text.</param>
void Handle(string commandText);
}
}

View file

@ -0,0 +1,18 @@
namespace MosswartMassacre
{
/// <summary>
/// Represents a start/stop service with life-cycle management.
/// </summary>
public interface IStreamService : System.IDisposable
{
/// <summary>
/// Starts the service.
/// </summary>
void Start();
/// <summary>
/// Stops the service.
/// </summary>
void Stop();
}
}

View file

@ -0,0 +1,23 @@
using System;
using System.Threading.Tasks;
namespace MosswartMassacre
{
/// <summary>
/// Service for WebSocket streaming and command handling.
/// </summary>
public interface IWebSocketService : IStreamService
{
/// <summary>
/// Fires when a valid command envelope arrives from the server for this character.
/// </summary>
event Action<CommandEnvelope> OnServerCommand;
/// <summary>
/// Sends chat text asynchronously over the WebSocket.
/// </summary>
/// <param name="colorIndex">Chat color index.</param>
/// <param name="chatText">Text to send.</param>
Task SendChatTextAsync(int colorIndex, string chatText);
}
}

View file

@ -48,6 +48,9 @@
<EmbedInteropTypes>False</EmbedInteropTypes>
<HintPath>lib\Decal.Interop.Inject.dll</HintPath>
</Reference>
<Reference Include="System.Configuration">
<HintPath>$(FrameworkPathOverride)\System.Configuration.dll</HintPath>
</Reference>
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
@ -76,7 +79,6 @@
<ItemGroup>
<Compile Include="vTank.cs" />
<Compile Include="VtankControl.cs" />
<Compile Include="Telemetry.cs" />
<Compile Include="Coordinates.cs" />
<Compile Include="Geometry.cs" />
<Compile Include="Utils.cs" />
@ -92,6 +94,14 @@
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<Compile Include="ViewSystemSelector.cs" />
<!-- WebSocket.cs replaced by WebSocketService -->
<Compile Include="StatsManager.cs" />
<Compile Include="ChatManager.cs" />
<Compile Include="ICommandHandler.cs" />
<Compile Include="CommandHandler.cs" />
<Compile Include="IStreamService.cs" />
<Compile Include="IWebSocketService.cs" />
<Compile Include="WebSocketService.cs" />
<Compile Include="Wrapper.cs" />
<Compile Include="Wrapper_Decal.cs" />
<Compile Include="Wrapper_MyHuds.cs" />

View file

@ -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;
/// <summary>
/// Handles server-sent commands via WebSocket.
/// </summary>
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<string> rareMessageQueue = new Queue<string>();
private static DateTime _lastSent = DateTime.MinValue;
private static readonly Queue<string> _chatQueue = new Queue<string>();
// Precompiled regex patterns for detecting kill messages
private static readonly Regex[] KillRegexes = new Regex[]
{
new Regex(@"^You flatten (?<targetname>.+)'s body with the force of your assault!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^You bring (?<targetname>.+) to a fiery end!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^You beat (?<targetname>.+) to a lifeless pulp!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^You smite (?<targetname>.+) mightily!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^You obliterate (?<targetname>.+)!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^You run (?<targetname>.+) through!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^You reduce (?<targetname>.+) to a sizzling, oozing mass!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^You knock (?<targetname>.+) into next Morningthaw!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^You split (?<targetname>.+) apart!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^You cleave (?<targetname>.+) in twain!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^You slay (?<targetname>.+) viciously enough to impart death several times over!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^You reduce (?<targetname>.+) to a drained, twisted corpse!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^Your killing blow nearly turns (?<targetname>.+) inside-out!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^Your attack stops (?<targetname>.+) cold!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^Your lightning coruscates over (?<targetname>.+)'s mortal remains!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^Your assault sends (?<targetname>.+) to an icy death!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^You killed (?<targetname>.+)!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^The thunder of crushing (?<targetname>.+) is followed by the deafening silence of death!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^The deadly force of your attack is so strong that (?<targetname>.+)'s ancestors feel it!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+)'s seared corpse smolders before you!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+) is reduced to cinders!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+) is shattered by your assault!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+) catches your attack, with dire consequences!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+) is utterly destroyed by your attack!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+) suffers a frozen fate!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+)'s perforated corpse falls before you!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+) is fatally punctured!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+)'s last strength dissolves before you!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+) is torn to ribbons by your assault!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+) is liquified by your attack!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+)'s last strength withers before you!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+) is dessicated by your attack!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^(?<targetname>.+) is incinerated by your assault!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
// Additional patterns from original killPatterns
new Regex(@"^(?<targetname>.+)'s death is preceded by a sharp, stabbing pain!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^Electricity tears (?<targetname>.+) apart!$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
new Regex(@"^Blistered by lightning, (?<targetname>.+) 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<ChatTextInterceptEventArgs>(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<ChatTextInterceptEventArgs>(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
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();
}
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} (?<command>.+)\""$";
string tag = Regex.Escape(PluginCore.CharTag);
string patterntag = $@"^\[Allegiance\].*Dunking Rares.*say[s]?, \""!dot {tag} (?<command>.+)\""$";
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);
}
}
// HTTP Telemetry removed
if (WebSocketEnabled)
_wsService?.Start();
}
catch (Exception ex)
/// <summary>
/// Determines if the chat text indicates a kill by this character.
/// </summary>
public static bool IsKilledByMeMessage(string text)
{
WriteToChat("Error processing chat message: " + ex.Message);
}
}
private void OnChatCommand(object sender, ChatParserInterceptEventArgs e)
// Check each precompiled kill message pattern
foreach (var regex in KillRegexes)
{
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 (?<targetname>.+)'s body with the force of your assault!$",
@"^You bring (?<targetname>.+) to a fiery end!$",
@"^You beat (?<targetname>.+) to a lifeless pulp!$",
@"^You smite (?<targetname>.+) mightily!$",
@"^You obliterate (?<targetname>.+)!$",
@"^You run (?<targetname>.+) through!$",
@"^You reduce (?<targetname>.+) to a sizzling, oozing mass!$",
@"^You knock (?<targetname>.+) into next Morningthaw!$",
@"^You split (?<targetname>.+) apart!$",
@"^You cleave (?<targetname>.+) in twain!$",
@"^You slay (?<targetname>.+) viciously enough to impart death several times over!$",
@"^You reduce (?<targetname>.+) to a drained, twisted corpse!$",
@"^Your killing blow nearly turns (?<targetname>.+) inside-out!$",
@"^Your attack stops (?<targetname>.+) cold!$",
@"^Your lightning coruscates over (?<targetname>.+)'s mortal remains!$",
@"^Your assault sends (?<targetname>.+) to an icy death!$",
@"^You killed (?<targetname>.+)!$",
@"^The thunder of crushing (?<targetname>.+) is followed by the deafening silence of death!$",
@"^The deadly force of your attack is so strong that (?<targetname>.+)'s ancestors feel it!$",
@"^(?<targetname>.+)'s seared corpse smolders before you!$",
@"^(?<targetname>.+) is reduced to cinders!$",
@"^(?<targetname>.+) is shattered by your assault!$",
@"^(?<targetname>.+) catches your attack, with dire consequences!$",
@"^(?<targetname>.+) is utterly destroyed by your attack!$",
@"^(?<targetname>.+) suffers a frozen fate!$",
@"^(?<targetname>.+)'s perforated corpse falls before you!$",
@"^(?<targetname>.+) is fatally punctured!$",
@"^(?<targetname>.+)'s death is preceded by a sharp, stabbing pain!$",
@"^(?<targetname>.+) is torn to ribbons by your assault!$",
@"^(?<targetname>.+) is liquified by your attack!$",
@"^(?<targetname>.+)'s last strength dissolves before you!$",
@"^Electricity tears (?<targetname>.+) apart!$",
@"^Blistered by lightning, (?<targetname>.+) falls!$",
@"^(?<targetname>.+)'s last strength withers before you!$",
@"^(?<targetname>.+) is dessicated by your attack!$",
@"^(?<targetname>.+) is incinerated by your assault!$"
};
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)
/// <summary>
/// Determines if the chat text indicates a rare discovery by this character.
/// </summary>
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] ";
/// <summary>
/// Write a message to the chat with plugin prefix.
/// </summary>
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 <command>. 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 <enable|disable>");
}
}
else
{
WriteToChat("Usage: /mm telemetry <enable|disable>");
}
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 <enable|disable>");
}
}
else
{
WriteToChat("Usage: /mm http <enable|disable>");
}
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 <enable|disable>");
}
break;
default:
WriteToChat($"Unknown /mm command: {subCommand}. Try /mm help");
break;
}
}
}

View file

@ -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<PluginSettings>(yaml);
loaded = _deserializer.Deserialize<PluginSettings>(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
}
}
}
/// <summary>
/// Schedule settings to be saved after debounce interval.
/// </summary>
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(); }
}
}
}

View file

@ -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

View file

@ -0,0 +1,120 @@
using System;
using System.Timers;
namespace MosswartMassacre
{
/// <summary>
/// Manages kill and rare statistics, with periodic updates.
/// </summary>
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; }
/// <summary>
/// Raised when kill statistics change: total kills, kills per 5 min, kills per hour.
/// </summary>
public event Action<int, double, double> KillStatsUpdated;
/// <summary>
/// Raised when elapsed time updates (periodic timer).
/// </summary>
public event Action<TimeSpan> ElapsedTimeUpdated;
/// <summary>
/// Raised when rare count changes.
/// </summary>
public event Action<int> RareCountUpdated;
public StatsManager()
{
StatsStartTime = DateTime.Now;
LastKillTime = DateTime.Now;
_timer = new Timer(1000);
_timer.Elapsed += OnTimerElapsed;
}
/// <summary>
/// Start periodic updates.
/// </summary>
public void Start()
{
_timer.Start();
}
/// <summary>
/// Stop periodic updates.
/// </summary>
public void Stop()
{
_timer.Stop();
}
private void OnTimerElapsed(object sender, ElapsedEventArgs e)
{
RecalculateRates();
ElapsedTimeUpdated?.Invoke(DateTime.Now - StatsStartTime);
KillStatsUpdated?.Invoke(TotalKills, KillsPer5Min, KillsPerHour);
}
/// <summary>
/// Register a new kill, updating counts and raising events.
/// </summary>
public void RegisterKill()
{
TotalKills++;
LastKillTime = DateTime.Now;
RecalculateRates();
KillStatsUpdated?.Invoke(TotalKills, KillsPer5Min, KillsPerHour);
}
/// <summary>
/// Register a rare discovery and raise event.
/// </summary>
public void RegisterRare()
{
RareCount++;
RareCountUpdated?.Invoke(RareCount);
}
/// <summary>
/// Reset all statistics to zero and starting point.
/// </summary>
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();
}
}
}

View file

@ -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}");
}
}
}
}

View file

@ -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
{
/// <summary>
/// Envelope for commands received via WebSocket.
/// </summary>
public class CommandEnvelope
{
[JsonProperty("player_name")]
public string PlayerName { get; set; }
[JsonProperty("command")]
public string Command { get; set; }
}
/// <summary>
/// WebSocket service for sending chat and receiving server commands.
/// </summary>
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;
/// <summary>
/// Raised when a command arrives for this character.
/// </summary>
public event Action<CommandEnvelope> 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<byte>(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<byte>(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<byte>(buffer), token);
if (result.MessageType == WebSocketMessageType.Close) break;
var msg = Encoding.UTF8.GetString(buffer, 0, result.Count).Trim();
CommandEnvelope env;
try { env = JsonConvert.DeserializeObject<CommandEnvelope>(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();
}
}
}

View file

@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<appSettings>
<!-- WebSocket settings -->
<add key="WebSocketEndpoint" value="wss://mosswart.snakedesert.se/websocket/" />
<add key="WebSocketSharedSecret" value="your_shared_secret" />
<add key="WebSocketIntervalSec" value="5" />
</appSettings>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>

Binary file not shown.