diff --git a/.gitignore b/.gitignore index b7f7048..4a897d1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ Thumbs.db .codex/ .agents/ .worktrees/ + +# Frame recordings from live runs +*.jsonl diff --git a/src/OpenAC.MosswartMassacre/OpenAC.MosswartMassacre.csproj b/src/OpenAC.MosswartMassacre/OpenAC.MosswartMassacre.csproj index ef71e2a..3f905ce 100644 --- a/src/OpenAC.MosswartMassacre/OpenAC.MosswartMassacre.csproj +++ b/src/OpenAC.MosswartMassacre/OpenAC.MosswartMassacre.csproj @@ -1,4 +1,9 @@ + + + true + diff --git a/tools/FrameRecorder/FrameRecorder.csproj b/tools/FrameRecorder/FrameRecorder.csproj new file mode 100644 index 0000000..6fec3c7 --- /dev/null +++ b/tools/FrameRecorder/FrameRecorder.csproj @@ -0,0 +1,6 @@ + + + Exe + false + + diff --git a/tools/FrameRecorder/Program.cs b/tools/FrameRecorder/Program.cs new file mode 100644 index 0000000..7bdaf4a --- /dev/null +++ b/tools/FrameRecorder/Program.cs @@ -0,0 +1,172 @@ +// 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(' '));