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>
This commit is contained in:
erik 2026-02-27 07:56:13 +00:00
parent f9264f2767
commit 0713e96a99
6 changed files with 192 additions and 142 deletions

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

@ -308,7 +308,9 @@
<Compile Include="CommandRouter.cs" /> <Compile Include="CommandRouter.cs" />
<Compile Include="Constants.cs" /> <Compile Include="Constants.cs" />
<Compile Include="GameEventRouter.cs" /> <Compile Include="GameEventRouter.cs" />
<Compile Include="IGameStats.cs" />
<Compile Include="IPluginLogger.cs" /> <Compile Include="IPluginLogger.cs" />
<Compile Include="QuestStreamingService.cs" />
<Compile Include="InventoryMonitor.cs" /> <Compile Include="InventoryMonitor.cs" />
<Compile Include="KillTracker.cs" /> <Compile Include="KillTracker.cs" />
<Compile Include="RareTracker.cs" /> <Compile Include="RareTracker.cs" />

View file

@ -18,7 +18,7 @@ using Mag.Shared.Constants;
namespace MosswartMassacre namespace MosswartMassacre
{ {
[FriendlyName("Mosswart Massacre")] [FriendlyName("Mosswart Massacre")]
public class PluginCore : PluginBase, IPluginLogger public class PluginCore : PluginBase, IPluginLogger, IGameStats
{ {
// Hot Reload Support Properties // Hot Reload Support Properties
private static string _assemblyDirectory = null; private static string _assemblyDirectory = null;
@ -47,10 +47,9 @@ namespace MosswartMassacre
public static bool IsHotReload { get; set; } public static bool IsHotReload { get; set; }
internal static PluginHost MyHost; internal static PluginHost MyHost;
// Bridge properties for WebSocket telemetry until IGameStats migration (Phase 5) // Static bridge properties for VVSTabbedMainView (reads from manager instances)
private static InventoryMonitor _staticInventoryMonitor; private static InventoryMonitor _staticInventoryMonitor;
internal static int cachedPrismaticCount => _staticInventoryMonitor?.CachedPrismaticCount ?? 0; internal static int cachedPrismaticCount => _staticInventoryMonitor?.CachedPrismaticCount ?? 0;
// Bridge properties for WebSocket telemetry until IGameStats migration (Phase 5)
private static KillTracker _staticKillTracker; private static KillTracker _staticKillTracker;
internal static int totalKills => _staticKillTracker?.TotalKills ?? 0; internal static int totalKills => _staticKillTracker?.TotalKills ?? 0;
internal static double killsPerHour => _staticKillTracker?.KillsPerHour ?? 0; internal static double killsPerHour => _staticKillTracker?.KillsPerHour ?? 0;
@ -58,6 +57,16 @@ namespace MosswartMassacre
internal static int totalDeaths => _staticKillTracker?.TotalDeaths ?? 0; internal static int totalDeaths => _staticKillTracker?.TotalDeaths ?? 0;
internal static DateTime statsStartTime => _staticKillTracker?.StatsStartTime ?? DateTime.Now; internal static DateTime statsStartTime => _staticKillTracker?.StatsStartTime ?? DateTime.Now;
internal static DateTime lastKillTime => _staticKillTracker?.LastKillTime ?? DateTime.Now; internal static DateTime lastKillTime => _staticKillTracker?.LastKillTime ?? DateTime.Now;
// IGameStats explicit implementation (for WebSocket telemetry)
int IGameStats.TotalKills => _staticKillTracker?.TotalKills ?? 0;
double IGameStats.KillsPerHour => _staticKillTracker?.KillsPerHour ?? 0;
int IGameStats.SessionDeaths => _staticKillTracker?.SessionDeaths ?? 0;
int IGameStats.TotalDeaths => _staticKillTracker?.TotalDeaths ?? 0;
int IGameStats.CachedPrismaticCount => _staticInventoryMonitor?.CachedPrismaticCount ?? 0;
string IGameStats.CharTag => CharTag;
DateTime IGameStats.StatsStartTime => _staticKillTracker?.StatsStartTime ?? DateTime.Now;
private static Timer vitalsTimer; private static Timer vitalsTimer;
private static System.Windows.Forms.Timer commandTimer; private static System.Windows.Forms.Timer commandTimer;
private static Timer characterStatsTimer; private static Timer characterStatsTimer;
@ -122,7 +131,6 @@ namespace MosswartMassacre
// Quest Management for always-on quest streaming // Quest Management for always-on quest streaming
public static QuestManager questManager; public static QuestManager questManager;
private static Timer questStreamingTimer;
private static readonly Queue<string> _chatQueue = new Queue<string>(); private static readonly Queue<string> _chatQueue = new Queue<string>();
@ -132,6 +140,7 @@ namespace MosswartMassacre
private InventoryMonitor _inventoryMonitor; private InventoryMonitor _inventoryMonitor;
private ChatEventRouter _chatEventRouter; private ChatEventRouter _chatEventRouter;
private GameEventRouter _gameEventRouter; private GameEventRouter _gameEventRouter;
private QuestStreamingService _questStreamingService;
private CommandRouter _commandRouter; private CommandRouter _commandRouter;
protected override void Startup() protected override void Startup()
@ -236,6 +245,7 @@ namespace MosswartMassacre
vTank.Enable(); vTank.Enable();
// Set logger for WebSocket // Set logger for WebSocket
WebSocket.SetLogger(this); WebSocket.SetLogger(this);
WebSocket.SetGameStats(this);
//lyssna på commands //lyssna på commands
WebSocket.OnServerCommand += HandleServerCommand; WebSocket.OnServerCommand += HandleServerCommand;
//starta inventory. Hanterar subscriptions i den med //starta inventory. Hanterar subscriptions i den med
@ -305,14 +315,9 @@ namespace MosswartMassacre
commandTimer = null; commandTimer = null;
} }
// Stop and dispose quest streaming timer // Stop quest streaming service
if (questStreamingTimer != null) _questStreamingService?.Stop();
{ _questStreamingService = null;
questStreamingTimer.Stop();
questStreamingTimer.Elapsed -= OnQuestStreamingUpdate;
questStreamingTimer.Dispose();
questStreamingTimer = null;
}
// Stop and dispose character stats timer // Stop and dispose character stats timer
if (characterStatsTimer != null) if (characterStatsTimer != null)
@ -427,11 +432,9 @@ namespace MosswartMassacre
// Trigger full quest data refresh (same as clicking refresh button) // Trigger full quest data refresh (same as clicking refresh button)
Views.FlagTrackerView.RefreshQuestData(); Views.FlagTrackerView.RefreshQuestData();
// Initialize quest streaming timer (30 seconds) // Initialize quest streaming service (30 seconds)
questStreamingTimer = new Timer(Constants.QuestStreamingIntervalMs); _questStreamingService = new QuestStreamingService(this);
questStreamingTimer.Elapsed += OnQuestStreamingUpdate; _questStreamingService.Start();
questStreamingTimer.AutoReset = true;
questStreamingTimer.Start();
WriteToChat("[OK] Quest streaming initialized with full data refresh"); WriteToChat("[OK] Quest streaming initialized with full data refresh");
} }
@ -470,102 +473,6 @@ namespace MosswartMassacre
} }
#region Quest Streaming Methods
private static void OnQuestStreamingUpdate(object sender, ElapsedEventArgs e)
{
try
{
// Debug: Log when timer fires
if (PluginSettings.Instance?.VerboseLogging == true)
{
WriteToChat("[QUEST-STREAM] Timer fired, checking conditions...");
}
// Stream high priority quest data via WebSocket
if (!WebSocketEnabled)
{
if (PluginSettings.Instance?.VerboseLogging == true)
{
WriteToChat("[QUEST-STREAM] WebSocket not enabled, skipping");
}
return;
}
if (questManager?.QuestList == null || questManager.QuestList.Count == 0)
{
if (PluginSettings.Instance?.VerboseLogging == true)
{
WriteToChat($"[QUEST-STREAM] No quest data available (null: {questManager?.QuestList == null}, count: {questManager?.QuestList?.Count ?? 0})");
}
return;
}
var currentTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
// Find and stream priority quests (deduplicated by quest ID)
var priorityQuests = questManager.QuestList
.Where(q => IsHighPriorityQuest(q.Id))
.GroupBy(q => q.Id)
.Select(g => g.First()) // Take first occurrence of each quest ID
.ToList();
if (PluginSettings.Instance?.VerboseLogging == true)
{
WriteToChat($"[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)
{
WriteToChat($"[QUEST-STREAM] Sending: {questName} - {countdown}");
}
// Stream quest data
System.Threading.Tasks.Task.Run(() => WebSocket.SendQuestDataAsync(questName, countdown));
}
catch (Exception ex)
{
WriteToChat($"[QUEST-STREAM] Error streaming quest {quest.Id}: {ex.Message}");
}
}
}
catch (Exception ex)
{
WriteToChat($"[QUEST-STREAM] Error in timer handler: {ex.Message}");
}
}
private static bool IsHighPriorityQuest(string questId)
{
return questId == "stipendtimer_0812" || // Changed from stipendtimer_monthly to stipendtimer_0812
questId == "augmentationblankgemacquired" ||
questId == "insatiableeaterjaw";
}
private 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";
}
#endregion
private void InitializeForHotReload() private void InitializeForHotReload()
{ {
@ -654,29 +561,18 @@ namespace MosswartMassacre
WriteToChat($"[ERROR] Quest manager hot reload failed: {ex.Message}"); WriteToChat($"[ERROR] Quest manager hot reload failed: {ex.Message}");
} }
// 9. Reinitialize quest streaming timer for hot reload // 9. Reinitialize quest streaming service for hot reload
try try
{ {
// Stop existing timer if any _questStreamingService?.Stop();
if (questStreamingTimer != null) _questStreamingService = new QuestStreamingService(this);
{ _questStreamingService.Start();
questStreamingTimer.Stop();
questStreamingTimer.Elapsed -= OnQuestStreamingUpdate;
questStreamingTimer.Dispose();
questStreamingTimer = null;
}
// Create new timer
questStreamingTimer = new Timer(Constants.QuestStreamingIntervalMs);
questStreamingTimer.Elapsed += OnQuestStreamingUpdate;
questStreamingTimer.AutoReset = true;
questStreamingTimer.Start();
WriteToChat("[OK] Quest streaming timer reinitialized (30s interval)"); WriteToChat("[OK] Quest streaming service reinitialized (30s interval)");
} }
catch (Exception ex) catch (Exception ex)
{ {
WriteToChat($"[ERROR] Quest streaming timer hot reload failed: {ex.Message}"); WriteToChat($"[ERROR] Quest streaming service hot reload failed: {ex.Message}");
} }
WriteToChat("Hot reload initialization completed!"); WriteToChat("Hot reload initialization completed!");
@ -1485,7 +1381,7 @@ namespace MosswartMassacre
try try
{ {
WriteToChat("=== Quest Streaming Status ==="); WriteToChat("=== Quest Streaming Status ===");
WriteToChat($"Timer Active: {questStreamingTimer != null && questStreamingTimer.Enabled}"); WriteToChat($"Timer Active: {_questStreamingService?.IsRunning ?? false}");
WriteToChat($"WebSocket Enabled: {WebSocketEnabled}"); WriteToChat($"WebSocket Enabled: {WebSocketEnabled}");
WriteToChat($"Quest Manager: {(questManager != null ? "Active" : "Not Active")}"); WriteToChat($"Quest Manager: {(questManager != null ? "Active" : "Not Active")}");
WriteToChat($"Quest Count: {questManager?.QuestList?.Count ?? 0}"); WriteToChat($"Quest Count: {questManager?.QuestList?.Count ?? 0}");
@ -1493,7 +1389,7 @@ namespace MosswartMassacre
if (questManager?.QuestList != null) if (questManager?.QuestList != null)
{ {
var priorityQuests = questManager.QuestList var priorityQuests = questManager.QuestList
.Where(q => IsHighPriorityQuest(q.Id)) .Where(q => QuestStreamingService.IsHighPriorityQuest(q.Id))
.GroupBy(q => q.Id) .GroupBy(q => q.Id)
.Select(g => g.First()) .Select(g => g.First())
.ToList(); .ToList();

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

@ -36,6 +36,7 @@ namespace MosswartMassacre
private const int IntervalSec = 5; private const int IntervalSec = 5;
private static string SessionId = ""; private static string SessionId = "";
private static IPluginLogger _logger; private static IPluginLogger _logger;
private static IGameStats _gameStats;
// ─── cached prismatic taper count ─── (now handled by PluginCore event system) // ─── cached prismatic taper count ─── (now handled by PluginCore event system)
@ -53,6 +54,7 @@ namespace MosswartMassacre
// ─── public API ───────────────────────────── // ─── public API ─────────────────────────────
public static void SetLogger(IPluginLogger logger) => _logger = logger; public static void SetLogger(IPluginLogger logger) => _logger = logger;
public static void SetGameStats(IGameStats gameStats) => _gameStats = gameStats;
public static void Start() public static void Start()
{ {
@ -350,33 +352,31 @@ namespace MosswartMassacre
// ─── payload builder ────────────────────────────── // ─── payload builder ──────────────────────────────
// Removed old cache system - now using PluginCore.cachedPrismaticCount
private static string BuildPayloadJson() private static string BuildPayloadJson()
{ {
var tele = new ClientTelemetry(); var tele = new ClientTelemetry();
var coords = Coordinates.Me; var coords = Coordinates.Me;
var stats = _gameStats;
var payload = new var payload = new
{ {
type = "telemetry", type = "telemetry",
character_name = CoreManager.Current.CharacterFilter.Name, character_name = CoreManager.Current.CharacterFilter.Name,
char_tag = PluginCore.CharTag, char_tag = stats?.CharTag ?? "",
session_id = SessionInfo.GuidString, session_id = SessionInfo.GuidString,
timestamp = DateTime.UtcNow.ToString("o"), timestamp = DateTime.UtcNow.ToString("o"),
ew = coords.EW, ew = coords.EW,
ns = coords.NS, ns = coords.NS,
z = coords.Z, z = coords.Z,
kills = PluginCore.totalKills, kills = stats?.TotalKills ?? 0,
kills_per_hour = PluginCore.killsPerHour.ToString("F0"), kills_per_hour = (stats?.KillsPerHour ?? 0).ToString("F0"),
onlinetime = (DateTime.Now - PluginCore.statsStartTime).ToString(@"dd\.hh\:mm\:ss"), onlinetime = (DateTime.Now - (stats?.StatsStartTime ?? DateTime.Now)).ToString(@"dd\.hh\:mm\:ss"),
deaths = PluginCore.sessionDeaths.ToString(), deaths = (stats?.SessionDeaths ?? 0).ToString(),
total_deaths = PluginCore.totalDeaths.ToString(), total_deaths = (stats?.TotalDeaths ?? 0).ToString(),
prismatic_taper_count = PluginCore.cachedPrismaticCount.ToString(), prismatic_taper_count = (stats?.CachedPrismaticCount ?? 0).ToString(),
vt_state = VtankControl.VtGetMetaState(), vt_state = VtankControl.VtGetMetaState(),
mem_mb = tele.MemoryBytes, mem_mb = tele.MemoryBytes,
cpu_pct = tele.GetCpuUsage(), cpu_pct = tele.GetCpuUsage(),
mem_handles = tele.HandleCount mem_handles = tele.HandleCount
}; };
return JsonConvert.SerializeObject(payload); return JsonConvert.SerializeObject(payload);
} }