Compare commits

...

6 commits

Author SHA1 Message Date
erik
64e690f625 Phase 6: Fix swallowed exceptions and cleanup unused usings
- Add debug logging to all empty catch blocks in DecalHarmonyClean.cs setup methods
  (prefix method catches intentionally stay silent to never break other plugins)
- Add error logging to VtankControl.VtSetSetting catch
- Add logging to DecalPatchMethods.ProcessInterceptedMessage catch
- Remove unused usings from PluginCore.cs (System.Diagnostics, System.Drawing,
  System.Text, System.Text.RegularExpressions)
- Flatten redundant nested try/catch in PatchPluginHost

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 07:59:34 +00:00
erik
0713e96a99 Phase 5: Extract QuestStreamingService and introduce IGameStats
- Extract QuestStreamingService.cs from PluginCore (timer, IsHighPriorityQuest, FormatCountdown)
- Create IGameStats interface for WebSocket telemetry decoupling
- PluginCore implements IGameStats, WebSocket.BuildPayloadJson reads from IGameStats
- WebSocket.cs no longer references PluginCore directly
- Update queststatus command to use QuestStreamingService
- Static bridge properties remain for VVSTabbedMainView compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 07:56:13 +00:00
erik
f9264f2767 Phase 4: Extract ChatEventRouter and GameEventRouter
- ChatEventRouter.cs: routes chat events to KillTracker, RareTracker, handles
  allegiance report trigger and WebSocket chat streaming
- GameEventRouter.cs: routes ServerDispatch messages (0xF7B0, 0x02CF) to CharacterStats
- PluginCore no longer contains OnChatText, AllChatText, NormalizeChatLine,
  or EchoFilter_ServerDispatch methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 07:50:41 +00:00
erik
c90e888d32 Phase 3: Extract RareTracker and InventoryMonitor
- RareTracker.cs: owns rare discovery detection, meta state toggle, WebSocket/allegiance notifications
- InventoryMonitor.cs: owns Prismatic Taper tracking with event-driven delta math
- PluginCore no longer contains inventory event handlers or rare detection logic
- Bridge properties maintain backward compat for WebSocket telemetry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 07:33:44 +00:00
erik
366cca8cb6 Phase 2: Extract IPluginLogger and KillTracker
- Create IPluginLogger interface, PluginCore implements it
- CharacterStats.cs and WebSocket.cs now use IPluginLogger instead of PluginCore.WriteToChat
- Extract KillTracker.cs: owns kill detection (all 36 regex patterns), death tracking,
  rate calculation, and the 1-sec stats update timer
- Bridge properties on PluginCore maintain backward compat for WebSocket telemetry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 07:29:49 +00:00
erik
4845a67c1f Phase 1: Extract Constants.cs and CommandRouter.cs
- Extract magic numbers (timer intervals, message type IDs, property keys) into Constants.cs
- Replace ~600-line HandleMmCommand switch with dictionary-based CommandRouter
- All /mm commands preserved with same behavior, now registered via lambdas
- PluginCore.cs and CharacterStats.cs updated to use named constants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 07:24:43 +00:00
17 changed files with 1510 additions and 1153 deletions

View file

@ -26,6 +26,8 @@ namespace MosswartMassacre
public static class CharacterStats
{
private static IPluginLogger _logger;
// Cached allegiance data (populated from network messages)
private static string allegianceName;
private static int allegianceSize;
@ -44,8 +46,9 @@ namespace MosswartMassacre
/// <summary>
/// Reset all cached data. Call on plugin init.
/// </summary>
internal static void Init()
internal static void Init(IPluginLogger logger = null)
{
_logger = logger;
allegianceName = null;
allegianceSize = 0;
followers = 0;
@ -112,7 +115,7 @@ namespace MosswartMassacre
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[CharStats] Allegiance processing error: {ex.Message}");
_logger?.Log($"[CharStats] Allegiance processing error: {ex.Message}");
}
}
@ -134,15 +137,15 @@ namespace MosswartMassacre
long key = tmpStruct.Value<Int64>("key");
long value = tmpStruct.Value<Int64>("value");
if (key == 6) // AvailableLuminance
if (key == Constants.AvailableLuminanceKey)
luminanceEarned = value;
else if (key == 7) // MaximumLuminance
else if (key == Constants.MaximumLuminanceKey)
luminanceTotal = value;
}
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[CharStats] Property processing error: {ex.Message}");
_logger?.Log($"[CharStats] Property processing error: {ex.Message}");
}
}
@ -162,14 +165,14 @@ namespace MosswartMassacre
int key = BitConverter.ToInt32(raw, 5);
long value = BitConverter.ToInt64(raw, 9);
if (key == 6) // AvailableLuminance
if (key == Constants.AvailableLuminanceKey)
luminanceEarned = value;
else if (key == 7) // MaximumLuminance
else if (key == Constants.MaximumLuminanceKey)
luminanceTotal = value;
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[CharStats] Int64 property update error: {ex.Message}");
_logger?.Log($"[CharStats] Int64 property update error: {ex.Message}");
}
}
@ -186,7 +189,7 @@ namespace MosswartMassacre
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[CharStats] Title processing error: {ex.Message}");
_logger?.Log($"[CharStats] Title processing error: {ex.Message}");
}
}
@ -201,7 +204,7 @@ namespace MosswartMassacre
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[CharStats] Set title error: {ex.Message}");
_logger?.Log($"[CharStats] Set title error: {ex.Message}");
}
}
@ -329,7 +332,7 @@ namespace MosswartMassacre
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[CharStats] Error collecting stats: {ex.Message}");
_logger?.Log($"[CharStats] Error collecting stats: {ex.Message}");
}
}
}

View file

@ -0,0 +1,90 @@
using System;
using System.Text.RegularExpressions;
using Decal.Adapter;
using Decal.Adapter.Wrappers;
namespace MosswartMassacre
{
/// <summary>
/// Routes chat events to the appropriate handler (KillTracker, RareTracker, etc.)
/// Replaces the big if/else chain in PluginCore.OnChatText.
/// </summary>
internal class ChatEventRouter
{
private readonly IPluginLogger _logger;
private readonly KillTracker _killTracker;
private RareTracker _rareTracker;
private readonly Action<int> _onRareCountChanged;
private readonly Action<string> _onAllegianceReport;
internal void SetRareTracker(RareTracker rareTracker) => _rareTracker = rareTracker;
internal ChatEventRouter(
IPluginLogger logger,
KillTracker killTracker,
RareTracker rareTracker,
Action<int> onRareCountChanged,
Action<string> onAllegianceReport)
{
_logger = logger;
_killTracker = killTracker;
_rareTracker = rareTracker;
_onRareCountChanged = onRareCountChanged;
_onAllegianceReport = onAllegianceReport;
}
internal void OnChatText(object sender, ChatTextInterceptEventArgs e)
{
try
{
_killTracker.CheckForKill(e.Text);
if (_rareTracker != null && _rareTracker.CheckForRare(e.Text, out string rareText))
{
_killTracker.RareCount = _rareTracker.RareCount;
_onRareCountChanged?.Invoke(_rareTracker.RareCount);
}
if (e.Color == 18 && e.Text.EndsWith("!report\""))
{
TimeSpan elapsed = DateTime.Now - _killTracker.StatsStartTime;
string reportMessage = $"Total Kills: {_killTracker.TotalKills}, Kills per Hour: {_killTracker.KillsPerHour:F2}, Elapsed Time: {elapsed:dd\\.hh\\:mm\\:ss}, Rares Found: {_rareTracker?.RareCount ?? 0}";
_logger?.Log($"[Mosswart Massacre] Reporting to allegiance: {reportMessage}");
_onAllegianceReport?.Invoke(reportMessage);
}
}
catch (Exception ex)
{
_logger?.Log("Error processing chat message: " + ex.Message);
}
}
/// <summary>
/// Streams all chat text to WebSocket (separate handler from the filtered one above).
/// </summary>
internal static async void AllChatText(object sender, ChatTextInterceptEventArgs e)
{
try
{
string cleaned = NormalizeChatLine(e.Text);
await WebSocket.SendChatTextAsync(e.Color, cleaned);
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[WS] Chat send failed: {ex}");
}
}
private static string NormalizeChatLine(string raw)
{
if (string.IsNullOrEmpty(raw))
return raw;
var noTags = Regex.Replace(raw, "<[^>]+>", "");
var trimmed = noTags.TrimEnd('\r', '\n');
var collapsed = Regex.Replace(trimmed, @"[ ]{2,}", " ");
return collapsed;
}
}
}

View file

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace MosswartMassacre
{
/// <summary>
/// Dictionary-based /mm command dispatcher. Commands are registered with descriptions
/// and routed by name lookup instead of a giant switch statement.
/// </summary>
internal class CommandRouter
{
private readonly Dictionary<string, (Action<string[]> handler, string description)> _commands
= new Dictionary<string, (Action<string[]>, string)>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Register a command with its handler and help description.
/// </summary>
internal void Register(string name, Action<string[]> handler, string description)
{
_commands[name] = (handler, description);
}
/// <summary>
/// Dispatch a raw /mm command string. Returns false if the command was not found.
/// </summary>
internal bool Dispatch(string rawText)
{
string[] args = rawText.Substring(3).Trim().Split(' ');
if (args.Length == 0 || string.IsNullOrEmpty(args[0]))
{
PluginCore.WriteToChat("Usage: /mm <command>. Try /mm help");
return true;
}
string subCommand = args[0].ToLower();
if (subCommand == "help")
{
PrintHelp();
return true;
}
if (_commands.TryGetValue(subCommand, out var entry))
{
entry.handler(args);
return true;
}
PluginCore.WriteToChat($"Unknown /mm command: {subCommand}. Try /mm help");
return false;
}
private void PrintHelp()
{
PluginCore.WriteToChat("Mosswart Massacre Commands:");
foreach (var kvp in _commands)
{
if (!string.IsNullOrEmpty(kvp.Value.description))
{
PluginCore.WriteToChat($"/mm {kvp.Key,-18} - {kvp.Value.description}");
}
}
}
}
}

View file

@ -0,0 +1,30 @@
namespace MosswartMassacre
{
/// <summary>
/// Centralized constants for timer intervals, message type IDs, and property keys.
/// </summary>
internal static class Constants
{
// Timer intervals (milliseconds)
internal const int StatsUpdateIntervalMs = 1000;
internal const int VitalsUpdateIntervalMs = 5000;
internal const int CommandProcessIntervalMs = 10;
internal const int QuestStreamingIntervalMs = 30000;
internal const int CharacterStatsIntervalMs = 600000; // 10 minutes
internal const int LoginDelayMs = 5000;
// Network message types
internal const int GameEventMessageType = 0xF7B0;
internal const int PrivateUpdatePropertyInt64 = 0x02CF;
// Game event IDs (sub-events within 0xF7B0)
internal const int AllegianceInfoEvent = 0x0020;
internal const int LoginCharacterEvent = 0x0013;
internal const int TitlesListEvent = 0x0029;
internal const int SetTitleEvent = 0x002b;
// Int64 property keys
internal const int AvailableLuminanceKey = 6;
internal const int MaximumLuminanceKey = 7;
}
}

View file

@ -62,9 +62,9 @@ namespace MosswartMassacre
// PATHWAY 2: Target Host.Actions.AddChatText (what our plugin uses)
PatchHostActions();
}
catch
catch (Exception ex)
{
// Only log if completely unable to apply any patches
AddDebugLog($"ApplyDecalPatches failed: {ex.Message}");
}
}
@ -92,13 +92,15 @@ namespace MosswartMassacre
{
ApplySinglePatch(method, prefixMethodName);
}
catch
catch (Exception ex)
{
AddDebugLog($"PatchHooksWrapper single patch failed ({prefixMethodName}): {ex.Message}");
}
}
}
catch
catch (Exception ex)
{
AddDebugLog($"PatchHooksWrapper failed: {ex.Message}");
}
}
@ -139,16 +141,18 @@ namespace MosswartMassacre
{
ApplySinglePatch(method, prefixMethodName);
}
catch
catch (Exception ex)
{
AddDebugLog($"PatchHostActions single patch failed ({prefixMethodName}): {ex.Message}");
}
}
// PATHWAY 3: Try to patch at PluginHost level
PatchPluginHost();
}
catch
catch (Exception ex)
{
AddDebugLog($"PatchHostActions failed: {ex.Message}");
}
}
@ -159,36 +163,31 @@ namespace MosswartMassacre
{
try
{
// Try to patch CoreManager.Current.Actions if it's different
try
var coreActions = CoreManager.Current?.Actions;
if (coreActions != null && coreActions != PluginCore.MyHost?.Actions)
{
var coreActions = CoreManager.Current?.Actions;
if (coreActions != null && coreActions != PluginCore.MyHost?.Actions)
var coreActionsMethods = coreActions.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance)
.Where(m => m.Name == "AddChatText" || m.Name == "AddChatTextRaw" || m.Name == "AddStatusText").ToArray();
foreach (var method in coreActionsMethods)
{
var coreActionsMethods = coreActions.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance)
.Where(m => m.Name == "AddChatText" || m.Name == "AddChatTextRaw" || m.Name == "AddStatusText").ToArray();
foreach (var method in coreActionsMethods)
var parameters = method.GetParameters();
try
{
var parameters = method.GetParameters();
try
{
string prefixMethodName = "AddChatTextPrefixCore" + parameters.Length;
ApplySinglePatch(method, prefixMethodName);
}
catch
{
}
string prefixMethodName = "AddChatTextPrefixCore" + parameters.Length;
ApplySinglePatch(method, prefixMethodName);
}
catch (Exception ex)
{
AddDebugLog($"PatchPluginHost single patch failed: {ex.Message}");
}
}
}
catch
{
}
}
catch
catch (Exception ex)
{
AddDebugLog($"PatchPluginHost failed: {ex.Message}");
}
}
@ -200,9 +199,9 @@ namespace MosswartMassacre
try
{
// Get our prefix method
var prefixMethod = typeof(DecalPatchMethods).GetMethod(prefixMethodName,
var prefixMethod = typeof(DecalPatchMethods).GetMethod(prefixMethodName,
BindingFlags.Static | BindingFlags.Public);
if (prefixMethod != null)
{
// Use UtilityBelt's exact approach
@ -210,8 +209,9 @@ namespace MosswartMassacre
patchesApplied = true;
}
}
catch
catch (Exception ex)
{
AddDebugLog($"ApplySinglePatch failed ({prefixMethodName}): {ex.Message}");
}
}
@ -244,8 +244,9 @@ namespace MosswartMassacre
}
patchesApplied = false;
}
catch
catch (Exception ex)
{
AddDebugLog($"Cleanup failed: {ex.Message}");
}
}
@ -390,8 +391,9 @@ namespace MosswartMassacre
Task.Run(() => WebSocket.SendChatTextAsync(color, text));
}
}
catch
catch (Exception ex)
{
DecalHarmonyClean.AddDebugLog($"ProcessInterceptedMessage failed: {ex.Message}");
}
}

View file

@ -0,0 +1,55 @@
using System;
using Decal.Adapter;
namespace MosswartMassacre
{
/// <summary>
/// Routes EchoFilter.ServerDispatch network messages to the appropriate handlers.
/// Owns the routing of 0xF7B0 sub-events and 0x02CF to CharacterStats.
/// </summary>
internal class GameEventRouter
{
private readonly IPluginLogger _logger;
internal GameEventRouter(IPluginLogger logger)
{
_logger = logger;
}
internal void OnServerDispatch(object sender, NetworkMessageEventArgs e)
{
try
{
if (e.Message.Type == Constants.GameEventMessageType)
{
int eventId = (int)e.Message["event"];
if (eventId == Constants.AllegianceInfoEvent)
{
CharacterStats.ProcessAllegianceInfoMessage(e);
}
else if (eventId == Constants.LoginCharacterEvent)
{
CharacterStats.ProcessCharacterPropertyData(e);
}
else if (eventId == Constants.TitlesListEvent)
{
CharacterStats.ProcessTitlesMessage(e);
}
else if (eventId == Constants.SetTitleEvent)
{
CharacterStats.ProcessSetTitleMessage(e);
}
}
else if (e.Message.Type == Constants.PrivateUpdatePropertyInt64)
{
CharacterStats.ProcessPropertyInt64Update(e);
}
}
catch (Exception ex)
{
_logger?.Log($"[CharStats] ServerDispatch error: {ex.Message}");
}
}
}
}

View file

@ -0,0 +1,19 @@
using System;
namespace MosswartMassacre
{
/// <summary>
/// Provides game statistics for WebSocket telemetry payloads.
/// Replaces direct static field access on PluginCore.
/// </summary>
public interface IGameStats
{
int TotalKills { get; }
double KillsPerHour { get; }
int SessionDeaths { get; }
int TotalDeaths { get; }
int CachedPrismaticCount { get; }
string CharTag { get; }
DateTime StatsStartTime { get; }
}
}

View file

@ -0,0 +1,11 @@
namespace MosswartMassacre
{
/// <summary>
/// Interface for writing messages to the game chat window.
/// Eliminates direct PluginCore.WriteToChat() dependencies from manager classes.
/// </summary>
public interface IPluginLogger
{
void Log(string message);
}
}

View file

@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using Decal.Adapter;
using Decal.Adapter.Wrappers;
namespace MosswartMassacre
{
/// <summary>
/// Tracks Prismatic Taper inventory counts using event-driven delta math.
/// Avoids expensive inventory scans during gameplay.
/// </summary>
internal class InventoryMonitor
{
private readonly IPluginLogger _logger;
private readonly Dictionary<int, int> _trackedTaperContainers = new Dictionary<int, int>();
private readonly Dictionary<int, int> _lastKnownStackSizes = new Dictionary<int, int>();
internal int CachedPrismaticCount { get; private set; }
internal int LastPrismaticCount { get; private set; }
internal InventoryMonitor(IPluginLogger logger)
{
_logger = logger;
}
internal void Initialize()
{
try
{
LastPrismaticCount = CachedPrismaticCount;
CachedPrismaticCount = Utils.GetItemStackSize("Prismatic Taper");
_trackedTaperContainers.Clear();
_lastKnownStackSizes.Clear();
foreach (WorldObject wo in CoreManager.Current.WorldFilter.GetInventory())
{
if (wo.Name.Equals("Prismatic Taper", StringComparison.OrdinalIgnoreCase) &&
IsPlayerOwnedContainer(wo.Container))
{
int stackCount = wo.Values(LongValueKey.StackCount, 1);
_trackedTaperContainers[wo.Id] = wo.Container;
_lastKnownStackSizes[wo.Id] = stackCount;
}
}
}
catch (Exception ex)
{
_logger?.Log($"[TAPER] Error initializing count: {ex.Message}");
CachedPrismaticCount = 0;
LastPrismaticCount = 0;
_trackedTaperContainers.Clear();
_lastKnownStackSizes.Clear();
}
}
internal void OnInventoryCreate(object sender, CreateObjectEventArgs e)
{
try
{
var item = e.New;
if (IsPlayerOwnedContainer(item.Container) &&
item.Name.Equals("Prismatic Taper", StringComparison.OrdinalIgnoreCase))
{
LastPrismaticCount = CachedPrismaticCount;
int stackCount = item.Values(LongValueKey.StackCount, 1);
CachedPrismaticCount += stackCount;
_trackedTaperContainers[item.Id] = item.Container;
_lastKnownStackSizes[item.Id] = stackCount;
}
}
catch (Exception ex)
{
_logger?.Log($"[TAPER] Error in OnInventoryCreate: {ex.Message}");
}
}
internal void OnInventoryRelease(object sender, ReleaseObjectEventArgs e)
{
try
{
var item = e.Released;
if (item.Name.Equals("Prismatic Taper", StringComparison.OrdinalIgnoreCase))
{
if (_trackedTaperContainers.TryGetValue(item.Id, out int previousContainer))
{
if (IsPlayerOwnedContainer(previousContainer))
{
LastPrismaticCount = CachedPrismaticCount;
int stackCount = item.Values(LongValueKey.StackCount, 1);
CachedPrismaticCount -= stackCount;
}
_trackedTaperContainers.Remove(item.Id);
_lastKnownStackSizes.Remove(item.Id);
}
else
{
LastPrismaticCount = CachedPrismaticCount;
CachedPrismaticCount = Utils.GetItemStackSize("Prismatic Taper");
}
}
}
catch (Exception ex)
{
_logger?.Log($"[TAPER] Error in OnInventoryRelease: {ex.Message}");
}
}
internal void OnInventoryChange(object sender, ChangeObjectEventArgs e)
{
try
{
var item = e.Changed;
if (item.Name.Equals("Prismatic Taper", StringComparison.OrdinalIgnoreCase))
{
bool isInPlayerContainer = IsPlayerOwnedContainer(item.Container);
if (isInPlayerContainer)
{
bool wasAlreadyTracked = _trackedTaperContainers.ContainsKey(item.Id);
_trackedTaperContainers[item.Id] = item.Container;
int currentStack = item.Values(LongValueKey.StackCount, 1);
if (!wasAlreadyTracked)
{
LastPrismaticCount = CachedPrismaticCount;
CachedPrismaticCount += currentStack;
}
else if (_lastKnownStackSizes.TryGetValue(item.Id, out int previousStack))
{
int stackDelta = currentStack - previousStack;
if (stackDelta != 0)
{
LastPrismaticCount = CachedPrismaticCount;
CachedPrismaticCount += stackDelta;
}
}
_lastKnownStackSizes[item.Id] = currentStack;
}
}
}
catch (Exception ex)
{
_logger?.Log($"[TAPER] Error in OnInventoryChange: {ex.Message}");
}
}
internal void Cleanup()
{
_trackedTaperContainers.Clear();
_lastKnownStackSizes.Clear();
}
internal int TrackedTaperCount => _trackedTaperContainers.Count;
internal int KnownStackSizesCount => _lastKnownStackSizes.Count;
private static bool IsPlayerOwnedContainer(int containerId)
{
try
{
if (containerId == CoreManager.Current.CharacterFilter.Id)
return true;
WorldObject container = CoreManager.Current.WorldFilter[containerId];
if (container != null &&
container.ObjectClass == ObjectClass.Container &&
container.Container == CoreManager.Current.CharacterFilter.Id)
{
return true;
}
return false;
}
catch
{
return false;
}
}
}
}

View file

@ -0,0 +1,176 @@
using System;
using System.Text.RegularExpressions;
using System.Timers;
namespace MosswartMassacre
{
/// <summary>
/// Tracks kills, deaths, and kill rate calculations.
/// Owns the 1-second stats update timer.
/// </summary>
internal class KillTracker
{
private readonly IPluginLogger _logger;
private readonly Action<int, double, double> _onStatsUpdated;
private readonly Action<TimeSpan> _onElapsedUpdated;
private int _totalKills;
private int _sessionDeaths;
private int _totalDeaths;
private double _killsPer5Min;
private double _killsPerHour;
private DateTime _lastKillTime = DateTime.Now;
private DateTime _statsStartTime = DateTime.Now;
private Timer _updateTimer;
// Kill message patterns — all 35+ patterns preserved exactly
private static readonly 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!$"
};
internal int TotalKills => _totalKills;
internal double KillsPerHour => _killsPerHour;
internal double KillsPer5Min => _killsPer5Min;
internal int SessionDeaths => _sessionDeaths;
internal int TotalDeaths => _totalDeaths;
internal DateTime StatsStartTime => _statsStartTime;
internal DateTime LastKillTime => _lastKillTime;
internal int RareCount { get; set; }
/// <param name="logger">Logger for chat output</param>
/// <param name="onStatsUpdated">Callback(totalKills, killsPer5Min, killsPerHour) for UI updates</param>
/// <param name="onElapsedUpdated">Callback(elapsed) for UI elapsed time updates</param>
internal KillTracker(IPluginLogger logger, Action<int, double, double> onStatsUpdated, Action<TimeSpan> onElapsedUpdated)
{
_logger = logger;
_onStatsUpdated = onStatsUpdated;
_onElapsedUpdated = onElapsedUpdated;
}
internal void Start()
{
_updateTimer = new Timer(Constants.StatsUpdateIntervalMs);
_updateTimer.Elapsed += UpdateStats;
_updateTimer.Start();
}
internal void Stop()
{
if (_updateTimer != null)
{
_updateTimer.Stop();
_updateTimer.Dispose();
_updateTimer = null;
}
}
internal bool CheckForKill(string text)
{
if (IsKilledByMeMessage(text))
{
_totalKills++;
_lastKillTime = DateTime.Now;
CalculateKillsPerInterval();
_onStatsUpdated?.Invoke(_totalKills, _killsPer5Min, _killsPerHour);
return true;
}
return false;
}
internal void OnDeath()
{
_sessionDeaths++;
}
internal void SetTotalDeaths(int totalDeaths)
{
_totalDeaths = totalDeaths;
}
internal void RestartStats()
{
_totalKills = 0;
RareCount = 0;
_sessionDeaths = 0;
_statsStartTime = DateTime.Now;
_killsPer5Min = 0;
_killsPerHour = 0;
_logger?.Log($"Stats have been reset. Session deaths: {_sessionDeaths}, Total deaths: {_totalDeaths}");
_onStatsUpdated?.Invoke(_totalKills, _killsPer5Min, _killsPerHour);
}
private void UpdateStats(object sender, ElapsedEventArgs e)
{
try
{
TimeSpan elapsed = DateTime.Now - _statsStartTime;
_onElapsedUpdated?.Invoke(elapsed);
CalculateKillsPerInterval();
_onStatsUpdated?.Invoke(_totalKills, _killsPer5Min, _killsPerHour);
}
catch (Exception ex)
{
_logger?.Log("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)
{
foreach (string pattern in KillPatterns)
{
if (Regex.IsMatch(text, pattern))
return true;
}
return false;
}
}
}

View file

@ -304,6 +304,16 @@
<Compile Include="..\Shared\VCS_Connector.cs">
<Link>Shared\VCS_Connector.cs</Link>
</Compile>
<Compile Include="ChatEventRouter.cs" />
<Compile Include="CommandRouter.cs" />
<Compile Include="Constants.cs" />
<Compile Include="GameEventRouter.cs" />
<Compile Include="IGameStats.cs" />
<Compile Include="IPluginLogger.cs" />
<Compile Include="QuestStreamingService.cs" />
<Compile Include="InventoryMonitor.cs" />
<Compile Include="KillTracker.cs" />
<Compile Include="RareTracker.cs" />
<Compile Include="ClientTelemetry.cs" />
<Compile Include="DecalHarmonyClean.cs" />
<Compile Include="FlagTrackerData.cs" />

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,133 @@
using System;
using System.Linq;
using System.Timers;
namespace MosswartMassacre
{
/// <summary>
/// Streams high-priority quest timer data via WebSocket on a 30-second interval.
/// </summary>
internal class QuestStreamingService
{
private readonly IPluginLogger _logger;
private Timer _timer;
internal QuestStreamingService(IPluginLogger logger)
{
_logger = logger;
}
internal void Start()
{
_timer = new Timer(Constants.QuestStreamingIntervalMs);
_timer.Elapsed += OnTimerElapsed;
_timer.AutoReset = true;
_timer.Start();
}
internal void Stop()
{
if (_timer != null)
{
_timer.Stop();
_timer.Elapsed -= OnTimerElapsed;
_timer.Dispose();
_timer = null;
}
}
internal bool IsRunning => _timer != null && _timer.Enabled;
private void OnTimerElapsed(object sender, ElapsedEventArgs e)
{
try
{
if (PluginSettings.Instance?.VerboseLogging == true)
{
_logger?.Log("[QUEST-STREAM] Timer fired, checking conditions...");
}
if (!PluginCore.WebSocketEnabled)
{
if (PluginSettings.Instance?.VerboseLogging == true)
{
_logger?.Log("[QUEST-STREAM] WebSocket not enabled, skipping");
}
return;
}
var questManager = PluginCore.questManager;
if (questManager?.QuestList == null || questManager.QuestList.Count == 0)
{
if (PluginSettings.Instance?.VerboseLogging == true)
{
_logger?.Log($"[QUEST-STREAM] No quest data available (null: {questManager?.QuestList == null}, count: {questManager?.QuestList?.Count ?? 0})");
}
return;
}
var currentTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var priorityQuests = questManager.QuestList
.Where(q => IsHighPriorityQuest(q.Id))
.GroupBy(q => q.Id)
.Select(g => g.First())
.ToList();
if (PluginSettings.Instance?.VerboseLogging == true)
{
_logger?.Log($"[QUEST-STREAM] Found {priorityQuests.Count} priority quests to stream");
}
foreach (var quest in priorityQuests)
{
try
{
string questName = questManager.GetFriendlyQuestName(quest.Id);
long timeRemaining = quest.ExpireTime - currentTime;
string countdown = FormatCountdown(timeRemaining);
if (PluginSettings.Instance?.VerboseLogging == true)
{
_logger?.Log($"[QUEST-STREAM] Sending: {questName} - {countdown}");
}
System.Threading.Tasks.Task.Run(() => WebSocket.SendQuestDataAsync(questName, countdown));
}
catch (Exception ex)
{
_logger?.Log($"[QUEST-STREAM] Error streaming quest {quest.Id}: {ex.Message}");
}
}
}
catch (Exception ex)
{
_logger?.Log($"[QUEST-STREAM] Error in timer handler: {ex.Message}");
}
}
internal static bool IsHighPriorityQuest(string questId)
{
return questId == "stipendtimer_0812" ||
questId == "augmentationblankgemacquired" ||
questId == "insatiableeaterjaw";
}
internal static string FormatCountdown(long seconds)
{
if (seconds <= 0)
return "READY";
var timeSpan = TimeSpan.FromSeconds(seconds);
if (timeSpan.TotalDays >= 1)
return $"{(int)timeSpan.TotalDays}d {timeSpan.Hours:D2}h";
else if (timeSpan.TotalHours >= 1)
return $"{timeSpan.Hours}h {timeSpan.Minutes:D2}m";
else if (timeSpan.TotalMinutes >= 1)
return $"{timeSpan.Minutes}m {timeSpan.Seconds:D2}s";
else
return $"{timeSpan.Seconds}s";
}
}
}

View file

@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Decal.Adapter;
namespace MosswartMassacre
{
/// <summary>
/// Tracks rare item discoveries, handles rare meta state toggles,
/// and sends rare notifications via WebSocket.
/// </summary>
internal class RareTracker
{
private readonly IPluginLogger _logger;
private readonly string _characterName;
internal int RareCount { get; set; }
internal bool RareMetaEnabled { get; set; } = true;
internal RareTracker(IPluginLogger logger)
{
_logger = logger;
_characterName = CoreManager.Current.CharacterFilter.Name;
}
/// <summary>
/// Check if the chat text is a rare discovery by this character.
/// If so, increments count, triggers meta switch, allegiance announce, and WebSocket notification.
/// Returns true if a rare was found.
/// </summary>
internal bool CheckForRare(string text, out string rareText)
{
if (IsRareDiscoveryMessage(text, out rareText))
{
RareCount++;
if (RareMetaEnabled)
{
PluginCore.Decal_DispatchOnChatCommand("/vt setmetastate loot_rare");
}
DelayedCommandManager.AddDelayedCommand($"/a {rareText}", 3000);
_ = WebSocket.SendRareAsync(rareText);
return true;
}
return false;
}
internal void ToggleRareMeta()
{
PluginSettings.Instance.RareMetaEnabled = !PluginSettings.Instance.RareMetaEnabled;
RareMetaEnabled = PluginSettings.Instance.RareMetaEnabled;
}
private bool IsRareDiscoveryMessage(string text, out string rareTextOnly)
{
rareTextOnly = null;
string pattern = @"^(?<name>['A-Za-z ]+)\shas discovered the (?<item>.*?)!$";
Match match = Regex.Match(text, pattern);
if (match.Success && match.Groups["name"].Value == _characterName)
{
rareTextOnly = match.Groups["item"].Value;
return true;
}
return false;
}
}
}

View file

@ -72,9 +72,9 @@ namespace MosswartMassacre
return 0;
}
}
catch
catch (Exception ex)
{
// Swallow any errors and signal failure
PluginCore.WriteToChat($"[VTank] SetSetting error ({setting}): {ex.Message}");
return 0;
}

View file

@ -35,6 +35,8 @@ namespace MosswartMassacre
private const string SharedSecret = "your_shared_secret";
private const int IntervalSec = 5;
private static string SessionId = "";
private static IPluginLogger _logger;
private static IGameStats _gameStats;
// ─── cached prismatic taper count ─── (now handled by PluginCore event system)
@ -51,13 +53,16 @@ namespace MosswartMassacre
// ─── public API ─────────────────────────────
public static void SetLogger(IPluginLogger logger) => _logger = logger;
public static void SetGameStats(IGameStats gameStats) => _gameStats = gameStats;
public static void Start()
{
if (_enabled) return;
_enabled = true;
_cts = new CancellationTokenSource();
PluginCore.WriteToChat("[WebSocket] connecting…");
_logger?.Log("[WebSocket] connecting…");
_ = Task.Run(ConnectAndLoopAsync);
}
@ -72,7 +77,7 @@ namespace MosswartMassacre
_ws?.Dispose();
_ws = null;
PluginCore.WriteToChat("[WebSocket] DISABLED");
_logger?.Log("[WebSocket] DISABLED");
}
// ─── connect / receive / telemetry loop ──────────────────────
@ -87,7 +92,7 @@ namespace MosswartMassacre
_ws = new ClientWebSocket();
_ws.Options.SetRequestHeader("X-Plugin-Secret", SharedSecret);
await _ws.ConnectAsync(WsEndpoint, _cts.Token);
PluginCore.WriteToChat("[WebSocket] CONNECTED");
_logger?.Log("[WebSocket] CONNECTED");
SessionId = $"{CoreManager.Current.CharacterFilter.Name}-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
// ─── Register this socket under our character name ───
@ -98,7 +103,7 @@ namespace MosswartMassacre
};
var regJson = JsonConvert.SerializeObject(registerEnvelope);
await SendEncodedAsync(regJson, _cts.Token);
PluginCore.WriteToChat("[WebSocket] REGISTERED");
_logger?.Log("[WebSocket] REGISTERED");
var buffer = new byte[4096];
@ -118,7 +123,7 @@ namespace MosswartMassacre
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[WebSocket] receive error: {ex.Message}");
_logger?.Log($"[WebSocket] receive error: {ex.Message}");
break;
}
@ -151,7 +156,7 @@ namespace MosswartMassacre
});
// 5) Inline telemetry loop
PluginCore.WriteToChat("[WebSocket] Starting telemetry loop");
_logger?.Log("[WebSocket] Starting telemetry loop");
while (_ws.State == WebSocketState.Open && !_cts.Token.IsCancellationRequested)
{
try
@ -161,7 +166,7 @@ namespace MosswartMassacre
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[WebSocket] Telemetry failed: {ex.Message}");
_logger?.Log($"[WebSocket] Telemetry failed: {ex.Message}");
break; // Exit telemetry loop on failure
}
@ -171,30 +176,30 @@ namespace MosswartMassacre
}
catch (OperationCanceledException)
{
PluginCore.WriteToChat("[WebSocket] Telemetry loop cancelled");
_logger?.Log("[WebSocket] Telemetry loop cancelled");
break;
}
}
// Log why telemetry loop exited
PluginCore.WriteToChat($"[WebSocket] Telemetry loop ended - State: {_ws?.State}, Cancelled: {_cts.Token.IsCancellationRequested}");
_logger?.Log($"[WebSocket] Telemetry loop ended - State: {_ws?.State}, Cancelled: {_cts.Token.IsCancellationRequested}");
// Wait for receive loop to finish
await receiveTask;
}
catch (OperationCanceledException)
{
PluginCore.WriteToChat("[WebSocket] Connection cancelled");
_logger?.Log("[WebSocket] Connection cancelled");
break;
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[WebSocket] Connection error: {ex.Message}");
_logger?.Log($"[WebSocket] Connection error: {ex.Message}");
}
finally
{
var finalState = _ws?.State.ToString() ?? "null";
PluginCore.WriteToChat($"[WebSocket] Cleaning up connection - Final state: {finalState}");
_logger?.Log($"[WebSocket] Cleaning up connection - Final state: {finalState}");
_ws?.Abort();
_ws?.Dispose();
_ws = null;
@ -203,7 +208,7 @@ namespace MosswartMassacre
// Pause before reconnecting
if (_enabled)
{
PluginCore.WriteToChat("[WebSocket] Reconnecting in 2 seconds...");
_logger?.Log("[WebSocket] Reconnecting in 2 seconds...");
try { await Task.Delay(2000, CancellationToken.None); } catch { }
}
}
@ -334,7 +339,7 @@ namespace MosswartMassacre
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[WebSocket] Send error: {ex.Message}");
_logger?.Log($"[WebSocket] Send error: {ex.Message}");
_ws?.Abort();
_ws?.Dispose();
_ws = null;
@ -347,33 +352,31 @@ namespace MosswartMassacre
// ─── payload builder ──────────────────────────────
// Removed old cache system - now using PluginCore.cachedPrismaticCount
private static string BuildPayloadJson()
{
var tele = new ClientTelemetry();
var coords = Coordinates.Me;
var stats = _gameStats;
var payload = new
{
type = "telemetry",
character_name = CoreManager.Current.CharacterFilter.Name,
char_tag = PluginCore.CharTag,
char_tag = stats?.CharTag ?? "",
session_id = SessionInfo.GuidString,
timestamp = DateTime.UtcNow.ToString("o"),
ew = coords.EW,
ns = coords.NS,
z = coords.Z,
kills = PluginCore.totalKills,
kills_per_hour = PluginCore.killsPerHour.ToString("F0"),
onlinetime = (DateTime.Now - PluginCore.statsStartTime).ToString(@"dd\.hh\:mm\:ss"),
deaths = PluginCore.sessionDeaths.ToString(),
total_deaths = PluginCore.totalDeaths.ToString(),
prismatic_taper_count = PluginCore.cachedPrismaticCount.ToString(),
kills = stats?.TotalKills ?? 0,
kills_per_hour = (stats?.KillsPerHour ?? 0).ToString("F0"),
onlinetime = (DateTime.Now - (stats?.StatsStartTime ?? DateTime.Now)).ToString(@"dd\.hh\:mm\:ss"),
deaths = (stats?.SessionDeaths ?? 0).ToString(),
total_deaths = (stats?.TotalDeaths ?? 0).ToString(),
prismatic_taper_count = (stats?.CachedPrismaticCount ?? 0).ToString(),
vt_state = VtankControl.VtGetMetaState(),
mem_mb = tele.MemoryBytes,
cpu_pct = tele.GetCpuUsage(),
mem_handles = tele.HandleCount
};
return JsonConvert.SerializeObject(payload);
}