// A stand-in for the Mosswart Overlord backend: accepts the plugin's // WebSocket, records every frame it sends to a JSON-lines file, and can // compare two recordings by frame shape. Used to prove the port sends what the // DECAL plugin sent. // // record listen on ws://localhost:/websocket/ // compare per frame type: field names, order and JSON types // send (while recording) push one frame to the client using System.Net; using System.Net.WebSockets; using System.Text; using System.Text.Json; return args.Length switch { 3 when args[0] == "record" => await Record(int.Parse(args[1]), args[2]), 3 when args[0] == "compare" => Compare(args[1], args[2]), _ => Usage(), }; static int Usage() { Console.Error.WriteLine("usage: record | compare "); return 2; } static async Task Record(int port, string outPath) { using var listener = new HttpListener(); listener.Prefixes.Add($"http://localhost:{port}/websocket/"); listener.Start(); Console.WriteLine($"recording on ws://localhost:{port}/websocket/ -> {outPath} (Ctrl+C to stop)"); using var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); listener.Stop(); }; await using var output = new StreamWriter(outPath, append: true, new UTF8Encoding(false)) { AutoFlush = true }; var writeLock = new SemaphoreSlim(1, 1); while (!cts.IsCancellationRequested) { HttpListenerContext context; try { context = await listener.GetContextAsync(); } catch { break; } if (!context.Request.IsWebSocketRequest) { context.Response.StatusCode = 400; context.Response.Close(); continue; } string secret = context.Request.Headers["X-Plugin-Secret"] ?? "(none)"; Console.WriteLine($"client connected; X-Plugin-Secret {(secret == "(none)" ? "missing" : "present")}"); HttpListenerWebSocketContext ws = await context.AcceptWebSocketAsync(null); _ = Task.Run(async () => { var buffer = new byte[65536]; using var message = new MemoryStream(); string? name = null; try { while (ws.WebSocket.State == WebSocketState.Open && !cts.IsCancellationRequested) { WebSocketReceiveResult result = await ws.WebSocket.ReceiveAsync(buffer, cts.Token); if (result.MessageType == WebSocketMessageType.Close) break; message.Write(buffer, 0, result.Count); if (!result.EndOfMessage) continue; string frame = Encoding.UTF8.GetString(message.ToArray()); message.SetLength(0); name = await Relay.Handle(frame, ws.WebSocket, name); await writeLock.WaitAsync(); try { await output.WriteLineAsync(frame); } finally { writeLock.Release(); } Console.WriteLine(frame.Length > 160 ? frame[..160] + "..." : frame); } } catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) { } Relay.Disconnected(name); Console.WriteLine("client disconnected"); }); } return 0; } static int Compare(string aPath, string bPath) { Dictionary> a = Shapes(aPath); Dictionary> b = Shapes(bPath); int problems = 0; foreach (string type in a.Keys.Union(b.Keys).OrderBy(t => t, StringComparer.Ordinal)) { a.TryGetValue(type, out SortedSet? inA); b.TryGetValue(type, out SortedSet? inB); if (inA is null || inB is null) { Console.WriteLine($"{type}: only in {(inA is null ? bPath : aPath)}"); continue; } if (inA.SetEquals(inB)) { Console.WriteLine($"{type}: same shape ({inA.Count} variant{(inA.Count == 1 ? "" : "s")})"); continue; } problems++; Console.WriteLine($"{type}: DIFFERENT"); foreach (string shape in inA.Except(inB)) Console.WriteLine($" only in A: {shape}"); foreach (string shape in inB.Except(inA)) Console.WriteLine($" only in B: {shape}"); } return problems == 0 ? 0 : 1; } // A frame's shape: its fields in order with their JSON types, nested objects // and the first element of arrays included, values left out. Dictionaries // keyed by data (monster names, property ids, skill names) are summarised by // the shape of their values. static Dictionary> Shapes(string path) { var shapes = new Dictionary>(StringComparer.Ordinal); foreach (string line in File.ReadLines(path)) { if (string.IsNullOrWhiteSpace(line)) continue; using JsonDocument doc = JsonDocument.Parse(line); JsonElement root = doc.RootElement; string type = root.ValueKind == JsonValueKind.Object && root.TryGetProperty("type", out JsonElement t) ? t.GetString() ?? "(null)" : "(no type)"; if (!shapes.TryGetValue(type, out SortedSet? set)) shapes[type] = set = new SortedSet(StringComparer.Ordinal); set.Add(Shape(root, depth: 0, keyed: false)); } return shapes; } static string Shape(JsonElement element, int depth, bool keyed) { switch (element.ValueKind) { case JsonValueKind.Object: { var properties = element.EnumerateObject().ToList(); bool dataKeyed = keyed || properties.Count > 0 && properties.All(p => LooksLikeData(p.Name)); if (dataKeyed && properties.Count > 0) return "{*:" + string.Join("|", properties.Select(p => Shape(p.Value, depth + 1, false)).Distinct().OrderBy(s => s, StringComparer.Ordinal)) + "}"; return "{" + string.Join(",", properties.Select(p => p.Name + ":" + Shape(p.Value, depth + 1, IsDataMap(p.Name)))) + "}"; } case JsonValueKind.Array: return element.GetArrayLength() == 0 ? "[]" : "[" + Shape(element[0], depth + 1, false) + "]"; case JsonValueKind.Number: return element.GetRawText().Contains('.') || element.GetRawText().Contains('E') ? "float" : "int"; default: return element.ValueKind.ToString().ToLowerInvariant(); } } // Objects whose keys are data, not field names. static bool IsDataMap(string name) => name is "monsters" or "properties" or "skills" or "attributes" or "vitals" or "IntValues" or "DoubleValues" or "BoolValues" or "StringValues" or "offense" or "defense"; static bool LooksLikeData(string key) => key.Length > 0 && (char.IsDigit(key[0]) || key.Contains(' ')); // The backend's vital-sharing relay: a client registers under its character // name; share_subscribe / share_unsubscribe opt it in and out; every other // share_* frame goes to every other opted-in client, unchanged. static class Relay { private static readonly object Gate = new(); private static readonly Dictionary Clients = new(StringComparer.Ordinal); private static readonly HashSet Subscribers = new(StringComparer.Ordinal); public static async Task Handle(string frame, WebSocket socket, string? name) { string? type; string? character; try { using JsonDocument doc = JsonDocument.Parse(frame); JsonElement root = doc.RootElement; type = root.TryGetProperty("type", out JsonElement t) ? t.GetString() : null; character = root.TryGetProperty("character_name", out JsonElement c) ? c.GetString() : root.TryGetProperty("player_name", out JsonElement p) ? p.GetString() : null; } catch (JsonException) { return name; } List targets = []; lock (Gate) { if (type == "register" && character is not null) { Clients[character] = socket; return character; } if (type == "share_subscribe" && character is not null) { Subscribers.Add(character); return name; } if (type == "share_unsubscribe" && character is not null) { Subscribers.Remove(character); return name; } if (type is null || !type.StartsWith("share_", StringComparison.Ordinal)) return name; foreach ((string client, WebSocket other) in Clients) { if (client != character && Subscribers.Contains(client) && other.State == WebSocketState.Open) targets.Add(other); } } byte[] bytes = Encoding.UTF8.GetBytes(frame); foreach (WebSocket target in targets) { try { await target.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None); } catch (WebSocketException) { } } return name; } public static void Disconnected(string? name) { if (name is null) return; lock (Gate) { Clients.Remove(name); Subscribers.Remove(name); } } }