// 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(); 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); 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) { } 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(' '));