MosswartMassacre/MosswartMassacre/WebSocket.cs
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

384 lines
15 KiB
C#

// WebSocket.cs
using System;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Decal.Adapter;
using Newtonsoft.Json;
using uTank2;
namespace MosswartMassacre
{
internal static class SessionInfo
{
internal static readonly Guid Guid = Guid.NewGuid();
internal static readonly string GuidString = Guid.ToString("N");
}
// 1) The envelope type for incoming commands
public class CommandEnvelope
{
[JsonProperty("player_name")]
public string PlayerName { get; set; }
[JsonProperty("command")]
public string Command { get; set; }
}
public static class WebSocket
{
// ─── configuration ──────────────────────────
private static readonly Uri WsEndpoint = new Uri("wss://overlord.snakedesert.se/websocket/");
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)
// ─── runtime state ──────────────────────────
private static ClientWebSocket _ws;
private static CancellationTokenSource _cts;
private static bool _enabled;
private static readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
/// <summary>
/// Fires when a valid CommandEnvelope arrives for this character.
/// </summary>
public static event Action<CommandEnvelope> OnServerCommand;
// ─── 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();
_logger?.Log("[WebSocket] connecting…");
_ = Task.Run(ConnectAndLoopAsync);
}
public static void Stop()
{
if (!_enabled) return;
_enabled = false;
_cts.Cancel();
_ws?.Abort();
_ws?.Dispose();
_ws = null;
_logger?.Log("[WebSocket] DISABLED");
}
// ─── connect / receive / telemetry loop ──────────────────────
private static async Task ConnectAndLoopAsync()
{
while (_enabled && !_cts.IsCancellationRequested)
{
try
{
// 1) Establish connection
_ws = new ClientWebSocket();
_ws.Options.SetRequestHeader("X-Plugin-Secret", SharedSecret);
await _ws.ConnectAsync(WsEndpoint, _cts.Token);
_logger?.Log("[WebSocket] CONNECTED");
SessionId = $"{CoreManager.Current.CharacterFilter.Name}-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
// ─── Register this socket under our character name ───
var registerEnvelope = new
{
type = "register",
player_name = CoreManager.Current.CharacterFilter.Name
};
var regJson = JsonConvert.SerializeObject(registerEnvelope);
await SendEncodedAsync(regJson, _cts.Token);
_logger?.Log("[WebSocket] REGISTERED");
var buffer = new byte[4096];
// 2) Fire-and-forget receive loop
var receiveTask = Task.Run(async () =>
{
while (_ws.State == WebSocketState.Open && !_cts.Token.IsCancellationRequested)
{
WebSocketReceiveResult result;
try
{
result = await _ws.ReceiveAsync(new ArraySegment<byte>(buffer), _cts.Token);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger?.Log($"[WebSocket] receive error: {ex.Message}");
break;
}
if (result.MessageType == WebSocketMessageType.Close)
break;
var msg = Encoding.UTF8.GetString(buffer, 0, result.Count).Trim();
// 3) Parse into CommandEnvelope
CommandEnvelope env;
try
{
env = JsonConvert.DeserializeObject<CommandEnvelope>(msg);
}
catch (JsonException)
{
continue; // skip malformed JSON
}
// 4) Filter by this character name
if (string.Equals(
env.PlayerName,
CoreManager.Current.CharacterFilter.Name,
StringComparison.OrdinalIgnoreCase))
{
// Fire event immediately - let PluginCore handle threading
OnServerCommand?.Invoke(env);
}
}
});
// 5) Inline telemetry loop
_logger?.Log("[WebSocket] Starting telemetry loop");
while (_ws.State == WebSocketState.Open && !_cts.Token.IsCancellationRequested)
{
try
{
var json = BuildPayloadJson();
await SendEncodedAsync(json, _cts.Token);
}
catch (Exception ex)
{
_logger?.Log($"[WebSocket] Telemetry failed: {ex.Message}");
break; // Exit telemetry loop on failure
}
try
{
await Task.Delay(TimeSpan.FromSeconds(IntervalSec), _cts.Token);
}
catch (OperationCanceledException)
{
_logger?.Log("[WebSocket] Telemetry loop cancelled");
break;
}
}
// Log why telemetry loop exited
_logger?.Log($"[WebSocket] Telemetry loop ended - State: {_ws?.State}, Cancelled: {_cts.Token.IsCancellationRequested}");
// Wait for receive loop to finish
await receiveTask;
}
catch (OperationCanceledException)
{
_logger?.Log("[WebSocket] Connection cancelled");
break;
}
catch (Exception ex)
{
_logger?.Log($"[WebSocket] Connection error: {ex.Message}");
}
finally
{
var finalState = _ws?.State.ToString() ?? "null";
_logger?.Log($"[WebSocket] Cleaning up connection - Final state: {finalState}");
_ws?.Abort();
_ws?.Dispose();
_ws = null;
}
// Pause before reconnecting
if (_enabled)
{
_logger?.Log("[WebSocket] Reconnecting in 2 seconds...");
try { await Task.Delay(2000, CancellationToken.None); } catch { }
}
}
}
// ─── fire-and-forget chat sender ────────────────────
public static async Task SendChatTextAsync(int colorIndex, string chatText)
{
var envelope = new
{
type = "chat",
timestamp = DateTime.UtcNow.ToString("o"),
character_name = CoreManager.Current.CharacterFilter.Name,
text = chatText,
color = colorIndex
};
var json = JsonConvert.SerializeObject(envelope);
await SendEncodedAsync(json, CancellationToken.None);
}
public static async Task SendSpawnAsync(string nsCoord, string ewCoord, string zCoord, string monster)
{
var envelope = new
{
type = "spawn",
timestamp = DateTime.UtcNow.ToString("o"),
character_name = CoreManager.Current.CharacterFilter.Name,
mob = monster,
ns = nsCoord,
ew = ewCoord,
z = zCoord
};
var json = JsonConvert.SerializeObject(envelope);
await SendEncodedAsync(json, CancellationToken.None);
}
public static async Task SendPortalAsync(string nsCoord, string ewCoord, string zCoord, string portalName)
{
var envelope = new
{
type = "portal",
timestamp = DateTime.UtcNow.ToString("o"),
character_name = CoreManager.Current.CharacterFilter.Name,
portal_name = portalName,
ns = nsCoord,
ew = ewCoord,
z = zCoord
};
var json = JsonConvert.SerializeObject(envelope);
await SendEncodedAsync(json, CancellationToken.None);
}
public static async Task SendRareAsync(string rare)
{
var coords = Coordinates.Me;
var envelope = new
{
type = "rare",
timestamp = DateTime.UtcNow.ToString("o"),
character_name = CoreManager.Current.CharacterFilter.Name,
name = rare,
ew = coords.EW,
ns = coords.NS,
z = coords.Z
};
var json = JsonConvert.SerializeObject(envelope);
await SendEncodedAsync(json, CancellationToken.None);
}
public static async Task SendFullInventoryAsync(List<Mag.Shared.MyWorldObject> inventory)
{
var envelope = new
{
type = "full_inventory",
timestamp = DateTime.UtcNow.ToString("o"),
character_name = CoreManager.Current.CharacterFilter.Name,
item_count = inventory.Count,
items = inventory
};
var json = JsonConvert.SerializeObject(envelope);
await SendEncodedAsync(json, CancellationToken.None);
}
public static async Task SendVitalsAsync(object vitalsData)
{
var json = JsonConvert.SerializeObject(vitalsData);
await SendEncodedAsync(json, CancellationToken.None);
}
public static async Task SendCharacterStatsAsync(object statsData)
{
var json = JsonConvert.SerializeObject(statsData);
await SendEncodedAsync(json, CancellationToken.None);
}
public static async Task SendQuestDataAsync(string questName, string countdown)
{
var envelope = new
{
type = "quest",
timestamp = DateTime.UtcNow.ToString("o"),
character_name = CoreManager.Current.CharacterFilter.Name,
quest_name = questName,
countdown = countdown
};
var json = JsonConvert.SerializeObject(envelope);
await SendEncodedAsync(json, CancellationToken.None);
}
// ─── shared send helper with locking ───────────────
private static async Task SendEncodedAsync(string text, CancellationToken token)
{
await _sendLock.WaitAsync(token);
try
{
if (_ws == null || _ws.State != WebSocketState.Open)
return;
var bytes = Encoding.UTF8.GetBytes(text);
await _ws.SendAsync(new ArraySegment<byte>(bytes),
WebSocketMessageType.Text,
true,
token);
}
catch (Exception ex)
{
_logger?.Log($"[WebSocket] Send error: {ex.Message}");
_ws?.Abort();
_ws?.Dispose();
_ws = null;
}
finally
{
_sendLock.Release();
}
}
// ─── payload builder ──────────────────────────────
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 = stats?.CharTag ?? "",
session_id = SessionInfo.GuidString,
timestamp = DateTime.UtcNow.ToString("o"),
ew = coords.EW,
ns = coords.NS,
z = coords.Z,
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);
}
}
}