diff --git a/DorkNet.Server.Tests/ChatStartConversationTests.cs b/DorkNet.Server.Tests/ChatStartConversationTests.cs
new file mode 100644
index 0000000..2885de1
--- /dev/null
+++ b/DorkNet.Server.Tests/ChatStartConversationTests.cs
@@ -0,0 +1,88 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text.Json;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// Starting a chat with a player is a two-step handshake, and both steps have to
+/// work on a thread that has never carried a message:
+///
+/// POST thread/withmembers?ids={playerId} -> creates/returns the thread
+/// GET thread/{id} -> opens it
+///
+/// Observed failing in production: the POST returned 200 at 06:38:20.862 and the
+/// GET 404'd 415ms later, because the handler decided a thread existed by
+/// counting its messages. A brand-new conversation has none, so the client was
+/// handed a thread id it was then told did not exist, and no chat could ever be
+/// opened with someone you had not already messaged.
+///
+public sealed class ChatStartConversationTests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public ChatStartConversationTests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public async Task Brand_new_conversation_can_be_opened_before_any_message_is_sent()
+ {
+ using var starterClient = Client();
+ using var otherClient = Client();
+
+ var starter = await GameClientSessionFactory.CreateAsync(starterClient, _factory.ApexDomain);
+ var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
+
+ starterClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", starter.AccessToken);
+
+ // Step 1 — the client asks for the thread with that player. It sends the
+ // ids as a form body (observed on the wire as
+ // "ids=9741717&messageCount=50"), which also matters for routing: the
+ // content type is what separates this from POST thread/{chatThreadId},
+ // whose parameter segment otherwise also matches "withmembers".
+ using var form = new FormUrlEncodedContent(
+ [
+ new KeyValuePair("ids", other.PlayerId.ToString()),
+ new KeyValuePair("messageCount", "50"),
+ ]);
+ using var createResponse = await starterClient.PostAsync("/thread/withmembers", form);
+ var createBody = await createResponse.Content.ReadAsStringAsync();
+ Assert.True(createResponse.IsSuccessStatusCode,
+ $"POST /thread/withmembers -> {(int)createResponse.StatusCode}: {createBody}");
+
+ var thread = JsonDocument.Parse(createBody).RootElement;
+ Assert.True(thread.TryGetProperty("ChatThreadId", out var idElement)
+ || thread.TryGetProperty("chatThreadId", out idElement),
+ $"no thread id in the withmembers reply: {createBody}");
+ var threadId = idElement.ValueKind == JsonValueKind.String
+ ? idElement.GetString()
+ : idElement.ToString();
+ Assert.False(string.IsNullOrWhiteSpace(threadId));
+
+ // Step 2 — and immediately opens it. No message has been sent yet.
+ using var openResponse = await starterClient.GetAsync($"/thread/{threadId}?maxCount=50");
+ var openBody = await openResponse.Content.ReadAsStringAsync();
+
+ Assert.True(openResponse.StatusCode != HttpStatusCode.NotFound,
+ $"""
+ GET /thread/{threadId} returned 404 for a thread that thread/withmembers
+ had just created. An empty thread is not a missing thread — the chat
+ cannot be opened until someone has already sent a message, which is
+ impossible to do first.
+ """);
+ Console.WriteLine("=== WITHMEMBERS ===");
+ Console.WriteLine(createBody);
+ Console.WriteLine("=== GET THREAD ===");
+ Console.WriteLine(openBody);
+
+ Assert.True(openResponse.IsSuccessStatusCode,
+ $"GET /thread/{threadId} -> {(int)openResponse.StatusCode}: {openBody}");
+ }
+
+ private HttpClient Client()
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://chat.{_factory.ApexDomain}");
+ return client;
+ }
+}
diff --git a/DorkNet.Server.Tests/ClientDtoParityTests.cs b/DorkNet.Server.Tests/ClientDtoParityTests.cs
new file mode 100644
index 0000000..d65ab7d
--- /dev/null
+++ b/DorkNet.Server.Tests/ClientDtoParityTests.cs
@@ -0,0 +1,202 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// Asserts our responses carry EXACTLY the keys the 2023-03-21 client's
+/// generated readers register — no missing ones, no invented ones.
+///
+/// This is stricter than "the client doesn't crash" on purpose. A wrong key set
+/// does not produce a bad request: the server answers HTTP 200, the reader
+/// throws while deserialising, and it surfaces later as a bare "Failed to copy
+/// room" with no URL and no payload logged on either side. By the time it is
+/// visible there is nothing left to diagnose it with.
+///
+/// The expected sets are extracted straight from the binary by
+/// tools/extract-client-dto-keys.py into
+/// data/client-dto-keys-2023.json, keyed by the obfuscated reader type.
+/// Cpp2IL emits each reader's property names as string literals in registration
+/// order, so that file is the client's own contract rather than our reading of
+/// it — regenerate it rather than editing it by hand.
+///
+public sealed class ClientDtoParityTests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public ClientDtoParityTests(DorkNetServerFactory factory) => _factory = factory;
+
+ /// GLEGPFFPDBE — the room-details reader. This is what
+ /// GET rooms/{id} and POST rooms/{id}/clone both deserialise
+ /// into (clone's return type FGCPNAACHIK, NLDBPDCNNCF.txt:4676).
+ private const string RoomDetailsReader = "GLEGPFFPDBE";
+
+ /// EKEFFNBIOHJ — the chat-thread reader, shared by
+ /// POST thread/withmembers and GET thread/{id}.
+ private const string ChatThreadReader = "EKEFFNBIOHJ";
+
+ /// Keys the reader registers that we deliberately do NOT send.
+ ///
+ /// A registered slot does not oblige us to fill it — Newtonsoft leaves an
+ /// absent key at its default. These two were added for parity and broke
+ /// the dorm: which blob a room loads is decided per INSTANCE (the matchmake
+ /// response carries it, and a dorm's is per-player, dorm_p{id}_v*.dat) and
+ /// per SUB-ROOM. A room-scoped answer to the same question is one the
+ /// client preferred, and for a dorm it named a file that was never written,
+ /// so the room came back "corrupt".
+ ///
+ /// Every room loaded fine while these were absent. Their absence is the
+ /// fix, not a gap to close later — do not "restore parity" here without a
+ /// dorm join to prove it.
+ private static readonly HashSet<(string Reader, string Key)> DeliberatelyOmitted =
+ [
+ (RoomDetailsReader, "DataBlob"),
+ (RoomDetailsReader, "DataBlobHash"),
+ ];
+
+ [Fact]
+ public async Task Room_details_carries_exactly_the_keys_the_client_reads()
+ {
+ using var client = RoomsClient();
+ var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", session.AccessToken);
+
+ var roomId = 9_600_000 + Random.Shared.Next(1, 99_999);
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = roomId,
+ Name = $"Parity{Guid.NewGuid():N}"[..18],
+ CreatorPlayerId = session.PlayerId,
+ CloningAllowed = true,
+ });
+ db.RoomScenes.Add(new RoomSceneEntity { RoomId = roomId, Name = "Home", OrderIndex = 0 });
+ await db.SaveChangesAsync();
+ }
+
+ using var response = await client.GetAsync($"/rooms/{roomId}");
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"GET /rooms/{roomId} -> {(int)response.StatusCode}: {body}");
+
+ AssertKeyParity(RoomDetailsReader, body, $"GET rooms/{roomId}");
+
+ // The reader walks into the nested objects too, and a wrong key set
+ // there fails exactly the same way — a 200 the client throws on.
+ var details = JsonDocument.Parse(body).RootElement;
+ foreach (var (property, reader) in NestedReaders)
+ {
+ if (!details.TryGetProperty(property, out var value)) continue;
+
+ var element = value.ValueKind switch
+ {
+ JsonValueKind.Array when value.GetArrayLength() > 0 => value[0],
+ JsonValueKind.Object => value,
+ _ => default,
+ };
+ if (element.ValueKind != JsonValueKind.Object) continue;
+
+ AssertKeyParity(reader, element.GetRawText(),
+ $"GET rooms/{roomId} → {property}{(value.ValueKind == JsonValueKind.Array ? "[0]" : "")}");
+ }
+ }
+
+ /// Nested objects in the room-details payload and the reader each
+ /// one is deserialised by. Sub-room 0, the first role, the stats object and
+ /// the first tag all have their own generated readers, and a key set that
+ /// disagrees with them breaks the whole room just as hard as a wrong
+ /// top-level one.
+ private static readonly (string Property, string Reader)[] NestedReaders =
+ [
+ ("SubRooms", "JDEKAFDPGBE"),
+ ("Roles", "LMEECKCCPHG"),
+ ("Stats", "MLHBPDKBABN"),
+ ("Tags", "GEGADPCDGBO"),
+ ];
+
+ [Fact]
+ public async Task Chat_thread_carries_exactly_the_keys_the_client_reads()
+ {
+ using var client = ChatClient();
+ using var otherClient = ChatClient();
+ var starter = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
+ var other = await GameClientSessionFactory.CreateAsync(otherClient, _factory.ApexDomain);
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", starter.AccessToken);
+
+ using var form = new FormUrlEncodedContent(
+ [
+ new KeyValuePair("ids", other.PlayerId.ToString()),
+ new KeyValuePair("messageCount", "50"),
+ ]);
+ using var response = await client.PostAsync("/thread/withmembers", form);
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode,
+ $"POST /thread/withmembers -> {(int)response.StatusCode}: {body}");
+
+ AssertKeyParity(ChatThreadReader, body, "POST thread/withmembers");
+ }
+
+ /// Compare one response object's keys against the reader's set.
+ /// Matching is case-insensitive because the readers register three spellings
+ /// per field (Pascal, camel, lower) and accept any of them — casing is the
+ /// one thing they are NOT strict about.
+ private static void AssertKeyParity(string readerType, string body, string what)
+ {
+ var expected = LoadReaderKeys(readerType)
+ .Where(k => !DeliberatelyOmitted.Contains((readerType, k)))
+ .ToList();
+ var actual = JsonDocument.Parse(body).RootElement
+ .EnumerateObject().Select(p => p.Name)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var missing = expected.Where(k => !actual.Contains(k)).ToList();
+ var extra = actual
+ .Where(k => !expected.Contains(k, StringComparer.OrdinalIgnoreCase))
+ .Order(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ Assert.True(missing.Count == 0 && extra.Count == 0,
+ $"""
+ {what} does not match what the client's {readerType} reader expects.
+
+ MISSING ({missing.Count}) — the reader registers these and we never send them;
+ a non-nullable field left null is what throws mid-deserialise:
+ {string.Join(", ", missing)}
+
+ UNEXPECTED ({extra.Count}) — we send these and the reader has no slot for them:
+ {string.Join(", ", extra)}
+
+ Expected, in the reader's own registration order:
+ {string.Join(", ", expected)}
+ """);
+ }
+
+ private static List LoadReaderKeys(string readerType)
+ {
+ var path = Path.Combine(AppContext.BaseDirectory, "data", "client-dto-keys-2023.json");
+ Assert.True(File.Exists(path),
+ $"missing {path} — regenerate with tools/extract-client-dto-keys.py");
+
+ using var doc = JsonDocument.Parse(File.ReadAllText(path));
+ Assert.True(doc.RootElement.TryGetProperty(readerType, out var keys),
+ $"{readerType} is not in the extracted catalogue");
+ return keys.EnumerateArray().Select(k => k.GetString()!).ToList();
+ }
+
+ private HttpClient RoomsClient() => ClientFor("rooms");
+
+ private HttpClient ChatClient() => ClientFor("chat");
+
+ private HttpClient ClientFor(string host)
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
+ return client;
+ }
+}
diff --git a/DorkNet.Server.Tests/ClientRouteCoverageTests.cs b/DorkNet.Server.Tests/ClientRouteCoverageTests.cs
new file mode 100644
index 0000000..1103de7
--- /dev/null
+++ b/DorkNet.Server.Tests/ClientRouteCoverageTests.cs
@@ -0,0 +1,115 @@
+using System.Text.RegularExpressions;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// Asserts that every HTTP request the Rec Room 2023-03-21 client can issue has
+/// a handler on this server, under the verb the client actually uses.
+///
+/// Rec Room is shut down, so the client can't be observed against a live server.
+/// data/client-routes-2023.tsv is the recovered inventory of what it
+/// asks for — see docs/recroom-2023-client-api-complete.md for how it was
+/// extracted. This test is the regression guard on that inventory: adding a
+/// client route without a handler, or moving a handler to a different verb,
+/// fails here rather than silently 404/405-ing a real player.
+///
+/// Routes not yet implemented live in . That list is
+/// expected to shrink and never grow. Deleting an entry as you implement it is
+/// the point — it is the project's running score against the client.
+///
+public sealed class ClientRouteCoverageTests
+{
+ /// Client routes with no handler yet. Each entry is
+ /// VERB route exactly as it appears in the inventory file.
+ ///
+ /// This is EMPTY, and that is the point: every request the 2023-03-21
+ /// client can issue reaches a handler under the verb it actually uses. Do
+ /// not add an entry to make a red build green — an entry here is a promise
+ /// that the client gets a 404 or 405 in the wild. Implement the route, or
+ /// if the inventory itself is wrong (a literal that is really a cache key,
+ /// a deeplink, or a base the client concatenates onto), fix the inventory
+ /// so the claim stays honest.
+ private static readonly HashSet KnownGaps = new(StringComparer.OrdinalIgnoreCase);
+
+ [Fact]
+ public void Every_client_route_has_a_handler()
+ {
+ var client = LoadClientRoutes();
+ Assert.NotEmpty(client);
+
+ var discovered = EndpointContractDiscovery.Discover()
+ .Select(r => (Verb: r.Method.ToUpperInvariant(), Pattern: Normalise(r.Template)))
+ .ToList();
+
+ var server = discovered.ToHashSet();
+
+ // A bare [Route] with no verb attribute serves every verb.
+ var verbless = discovered
+ .Where(d => d.Verb == "ANY")
+ .Select(d => d.Pattern)
+ .ToHashSet(StringComparer.Ordinal);
+
+ var serverAnyVerb = discovered.Select(s => s.Pattern).ToHashSet(StringComparer.Ordinal);
+
+ var missing = new List();
+ var wrongVerb = new List();
+
+ foreach (var (verb, route, _) in client)
+ {
+ var pattern = Normalise(route);
+ if (server.Contains((verb, pattern)) || verbless.Contains(pattern)) continue;
+
+ // Distinguish "no handler at all" from "handler exists, wrong verb" —
+ // they have different causes and different fixes.
+ if (serverAnyVerb.Contains(pattern)) wrongVerb.Add($"{verb} {route}");
+ else missing.Add($"{verb} {route}");
+ }
+
+ var gaps = missing.Concat(wrongVerb)
+ .Where(g => !KnownGaps.Contains(g))
+ .OrderBy(g => g, StringComparer.Ordinal)
+ .ToList();
+
+ var covered = client.Count - missing.Count - wrongVerb.Count;
+ var pct = 100.0 * covered / client.Count;
+
+ Assert.True(gaps.Count == 0,
+ $"""
+ Client route coverage: {covered}/{client.Count} ({pct:F1}%).
+ {missing.Count} missing, {wrongVerb.Count} wrong-verb, {KnownGaps.Count} known gaps.
+
+ Unlisted gaps ({gaps.Count}) — implement them, or add to KnownGaps with a reason:
+ {string.Join(Environment.NewLine, gaps)}
+ """);
+ }
+
+ /// Collapse a route to a comparable shape: no host, no leading or
+ /// trailing slash, no query, and every parameter — the client's {0}
+ /// and the server's {id:long} alike — reduced to *.
+ private static string Normalise(string route)
+ {
+ var r = route.Trim();
+ var q = r.IndexOf('?');
+ if (q >= 0) r = r[..q];
+ r = r.Trim('/', '~');
+ r = Regex.Replace(r, @"\{[^}]*\}", "*");
+ return r.ToLowerInvariant();
+ }
+
+ private static List<(string Verb, string Route, string Subsystem)> LoadClientRoutes()
+ {
+ var path = Path.Combine(AppContext.BaseDirectory, "data", "client-routes-2023.tsv");
+ Assert.True(File.Exists(path), $"missing route inventory at {path}");
+
+ var rows = new List<(string, string, string)>();
+ foreach (var line in File.ReadLines(path))
+ {
+ if (line.Length == 0 || line[0] == '#') continue;
+ var parts = line.Split('\t');
+ if (parts.Length < 2) continue;
+ rows.Add((parts[0].Trim().ToUpperInvariant(), parts[1].Trim(),
+ parts.Length > 2 ? parts[2].Trim() : string.Empty));
+ }
+ return rows;
+ }
+}
diff --git a/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj b/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj
index 71ce8e3..12a6c5e 100644
--- a/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj
+++ b/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj
@@ -24,4 +24,9 @@
+
+
+
+
+
diff --git a/DorkNet.Server.Tests/EndpointContractDiscovery.cs b/DorkNet.Server.Tests/EndpointContractDiscovery.cs
index abc75c9..44a68f1 100644
--- a/DorkNet.Server.Tests/EndpointContractDiscovery.cs
+++ b/DorkNet.Server.Tests/EndpointContractDiscovery.cs
@@ -36,7 +36,20 @@ public static partial class EndpointContractDiscovery
{
var httpAttrs = method.GetCustomAttributes(inherit: true)
.OfType()
+ .Cast()
.ToArray();
+
+ // An action can carry a bare [Route("...")] with no [HttpGet]/[HttpPost],
+ // which routes ALL verbs — the CDN's unity_assets handler is the live
+ // example. Skipping those made the discovered table under-report the
+ // server, so route-coverage checks saw phantom gaps.
+ if (httpAttrs.Length == 0)
+ {
+ httpAttrs = method.GetCustomAttributes(inherit: true)
+ .OfType()
+ .Where(a => !string.IsNullOrWhiteSpace(a.Template))
+ .ToArray();
+ }
if (httpAttrs.Length == 0)
{
continue;
@@ -44,8 +57,8 @@ public static partial class EndpointContractDiscovery
foreach (var attr in httpAttrs)
{
- var attrMethods = attr.HttpMethods.Any()
- ? attr.HttpMethods
+ var attrMethods = attr is HttpMethodAttribute { HttpMethods: var verbs } && verbs.Any()
+ ? verbs
: new[] { "ANY" };
foreach (var httpMethod in attrMethods)
diff --git a/DorkNet.Server.Tests/GameClientEndpointContractTests.cs b/DorkNet.Server.Tests/GameClientEndpointContractTests.cs
index c08eba1..3cde1df 100644
--- a/DorkNet.Server.Tests/GameClientEndpointContractTests.cs
+++ b/DorkNet.Server.Tests/GameClientEndpointContractTests.cs
@@ -18,7 +18,9 @@ public sealed class GameClientEndpointContractTests :
[Fact]
public async Task Game_client_contract_probe_reaches_every_controller_route()
{
- var routes = EndpointContractDiscovery.Discover();
+ var routes = EndpointContractDiscovery.Discover()
+ .Where(r => !DestroysTheProbeSession(r))
+ .ToList();
Assert.NotEmpty(routes);
using var client = _factory.CreateClient(new()
@@ -48,6 +50,26 @@ public sealed class GameClientEndpointContractTests :
+ string.Join(Environment.NewLine, failures));
}
+ /// Routes this probe must not call, because they destroy the very
+ /// session it authenticates with.
+ ///
+ /// The probe walks every route in order using one test player. Account
+ /// deletion is a genuine, correctly-guarded purge — but once it runs, that
+ /// player row is gone, so every later probe fails on a foreign-key
+ /// violation or a 401. Excluding these keeps the failure list meaningful
+ /// instead of one real event cascading into dozens of phantom failures.
+ ///
+ /// This is deliberately narrow: only self-destruct routes belong here, not
+ /// anything merely inconvenient.
+ private static bool DestroysTheProbeSession(EndpointContract route)
+ {
+ if (!string.Equals(route.Method, "DELETE", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ var template = route.Template.Trim('/');
+ return template is "account/me" or "account/v1/me" or "account/v2/me";
+ }
+
private static async Task ProbeAsync(
HttpClient client,
EndpointContract route,
diff --git a/DorkNet.Server.Tests/PendingJoinPresenceTests.cs b/DorkNet.Server.Tests/PendingJoinPresenceTests.cs
new file mode 100644
index 0000000..0ec57ec
--- /dev/null
+++ b/DorkNet.Server.Tests/PendingJoinPresenceTests.cs
@@ -0,0 +1,101 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// While a join is pending, the heartbeat must tell the joining player which
+/// instance they are joining — never null.
+///
+/// Deferring the presence commit is about what everyone ELSE sees: presence
+/// stays uncommitted until the join is confirmed so a failed join leaves no
+/// ghost behind. It must not change what the joining player is told about
+/// themselves, because the client XORs (cachedRoomInstance != null) against
+/// (serverRoomInstance != null): holding the target while the server says null
+/// reads as the room vanishing mid-join. The client then logs "presence
+/// heartbeat response indicates local presence is out-of-sync", "Attempting to
+/// go to Invalid RoomInstance", and cancels the very join the server was
+/// waiting on — so it retries until a heartbeat happens to miss the window, and
+/// entering a dorm takes several attempts while looking like a load failure.
+///
+public sealed class PendingJoinPresenceTests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public PendingJoinPresenceTests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public async Task Heartbeat_during_a_pending_dorm_join_reports_the_instance_being_joined()
+ {
+ using var client = Client("match");
+ var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", session.AccessToken);
+
+ // matchmake/dorm is what arms the deferred-commit pending join.
+ using var dormForm = new FormUrlEncodedContent(
+ [new KeyValuePair("MaxPersistenceVersion", "45")]);
+ using var dorm = await client.PostAsync("/matchmake/dorm", dormForm);
+ var dormBody = await dorm.Content.ReadAsStringAsync();
+ Assert.True(dorm.IsSuccessStatusCode,
+ $"POST /matchmake/dorm -> {(int)dorm.StatusCode}: {dormBody}");
+
+ var expectedInstance = JsonDocument.Parse(dormBody).RootElement;
+ var instanceId = FindInstanceId(expectedInstance);
+ Assert.True(instanceId is not null, $"no roomInstanceId in the dorm reply: {dormBody}");
+
+ // The join has NOT been reported yet, so the commit is still deferred —
+ // this is exactly the window the client heartbeats in.
+ using var beatForm = new FormUrlEncodedContent(
+ [new KeyValuePair("LoginLock", Guid.NewGuid().ToString())]);
+ using var beat = await client.PostAsync("/player/heartbeat", beatForm);
+ var beatBody = await beat.Content.ReadAsStringAsync();
+ Assert.True(beat.IsSuccessStatusCode,
+ $"POST /player/heartbeat -> {(int)beat.StatusCode}: {beatBody}");
+
+ var presence = JsonDocument.Parse(beatBody).RootElement;
+ Assert.True(presence.TryGetProperty("roomInstance", out var reported)
+ || presence.TryGetProperty("RoomInstance", out reported),
+ $"no roomInstance key in the heartbeat: {beatBody}");
+
+ Assert.True(reported.ValueKind != JsonValueKind.Null,
+ $"""
+ The heartbeat returned a null roomInstance while the player's join was
+ still pending. The client reads that as the room vanishing mid-join and
+ cancels the load it is waiting on:
+
+ {beatBody}
+ """);
+
+ Assert.Equal(instanceId, FindInstanceId(reported));
+ }
+
+ /// Pull roomInstanceId out of either the matchmake reply (which
+ /// wraps it) or the heartbeat's roomInstance object, whatever the casing.
+ private static string? FindInstanceId(JsonElement element)
+ {
+ if (element.ValueKind != JsonValueKind.Object) return null;
+
+ foreach (var property in element.EnumerateObject())
+ {
+ if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase))
+ return property.Value.ToString();
+
+ if (property.Value.ValueKind == JsonValueKind.Object
+ && FindInstanceId(property.Value) is { } nested)
+ return nested;
+ }
+ return null;
+ }
+
+ private HttpClient Client(string host)
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
+ return client;
+ }
+}
diff --git a/DorkNet.Server.Tests/RoomCloneShapeTests.cs b/DorkNet.Server.Tests/RoomCloneShapeTests.cs
new file mode 100644
index 0000000..0bb7c30
--- /dev/null
+++ b/DorkNet.Server.Tests/RoomCloneShapeTests.cs
@@ -0,0 +1,223 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using DorkNet.Server.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// The 2023 "copy room" flow POSTs rooms/{id}/clone and deserialises the
+/// reply into FGCPNAACHIK — the SAME type GET rooms/{id} returns
+/// (RecNet.Runtime/NLDBPDCNNCF.txt:4676 declares
+/// Task<FGCPNAACHIK> GDHIIAHCBMN(Int64, String, CancellationToken),
+/// with the route literal on :4802).
+///
+/// So the two responses must be shape-identical. When they drift, the server
+/// still answers 200 and the client throws while reading it — and because the
+/// clone promise's exception is never observed, it surfaces from the finalizer
+/// thread seconds later as a bare "Failed to copy room: Exception of type
+/// '...' was thrown", with nothing in the server log to show for it. That is
+/// exactly how this failed in the wild: the room WAS created, and the client
+/// simply never registered it.
+///
+public sealed class RoomCloneShapeTests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public RoomCloneShapeTests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public async Task Clone_response_matches_the_get_by_id_shape()
+ {
+ using var client = Client();
+ var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", session.AccessToken);
+
+ var sourceId = 9_400_000 + Random.Shared.Next(1, 99_999);
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = sourceId,
+ Name = $"CloneSrc{Guid.NewGuid():N}"[..20],
+ CreatorPlayerId = session.PlayerId,
+ CloningAllowed = true,
+ });
+ // Give the source a real sub-room. Without one, BOTH responses fall
+ // back to the synthesised default sub-room and the comparison never
+ // exercises the branch a real room (like the seeded RecCenter the
+ // room-creation picker clones) actually takes.
+ db.RoomScenes.Add(new RoomSceneEntity
+ {
+ RoomId = sourceId,
+ Name = "Home",
+ OrderIndex = 0,
+ });
+ await db.SaveChangesAsync();
+ }
+
+ var getBody = await ReadJsonAsync(client, HttpMethod.Get, $"/rooms/{sourceId}");
+ var cloneBody = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
+ new FormUrlEncodedContent([new KeyValuePair("name", $"Copy{Guid.NewGuid():N}"[..20])]));
+
+ var expected = getBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
+ var actual = cloneBody.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
+
+ var missing = expected.Except(actual).Order(StringComparer.Ordinal).ToList();
+
+ // The reader walks the nested sub-room list too, so a key missing there
+ // breaks it just as hard as one missing at the top level.
+ foreach (var list in new[] { "SubRooms", "Scenes" })
+ {
+ if (!getBody.TryGetProperty(list, out var expectedList) ||
+ expectedList.ValueKind != JsonValueKind.Array ||
+ expectedList.GetArrayLength() == 0) continue;
+
+ Assert.True(cloneBody.TryGetProperty(list, out var actualList)
+ && actualList.ValueKind == JsonValueKind.Array,
+ $"clone response has no {list} array while get-by-id does");
+ Assert.True(actualList.GetArrayLength() > 0,
+ $"clone response has an EMPTY {list} array while get-by-id returns "
+ + $"{expectedList.GetArrayLength()}; the client's reader requires at least one entry");
+
+ var expectedKeys = expectedList[0].EnumerateObject().Select(p => p.Name);
+ var actualKeys = actualList[0].EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
+ missing.AddRange(expectedKeys.Where(k => !actualKeys.Contains(k)).Select(k => $"{list}[0].{k}"));
+ }
+ Assert.True(missing.Count == 0,
+ $"""
+ The clone response is missing {missing.Count} key(s) that get-by-id
+ returns. Both deserialize into FGCPNAACHIK, so the client throws while
+ reading the clone and silently never registers the new room:
+
+ {string.Join(Environment.NewLine, missing)}
+
+ clone keys: {string.Join(", ", actual.Order(StringComparer.Ordinal))}
+ """);
+ }
+
+ /// A clone must own its save data. Cloning used to be a shallow
+ /// copy — the new room's scenes kept pointing at the SOURCE's blob until
+ /// someone saved over them — so every copy of a room shared one object and
+ /// cloning RecCenter produced rooms whose data blob was literally
+ /// room_100_v1.dat. A clone that references another room's blob is
+ /// the bug, whatever the name.
+ [Fact]
+ public async Task Clone_does_not_reference_the_source_rooms_blob()
+ {
+ // The suite runs with S3 unconfigured, where there is nothing to copy
+ // and the clone deliberately keeps the shallow reference. Swap in a
+ // real (in-memory) store so this exercises the copy path itself.
+ var store = new InMemoryObjectStorage();
+ using var factory = _factory.WithWebHostBuilder(builder =>
+ builder.ConfigureServices(services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(store);
+ }));
+
+ using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}");
+ var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", session.AccessToken);
+
+ var sourceId = 9_500_000 + Random.Shared.Next(1, 99_999);
+ var sourceBlob = $"room_{sourceId}_v1.dat";
+ var sourceBytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
+ var (srcBucket, srcKey) = BlobRouter.Route(sourceBlob);
+ await store.PutAsync(srcBucket, srcKey, sourceBytes, "application/octet-stream");
+
+ await using (var scope = factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = sourceId,
+ Name = $"BlobSrc{Guid.NewGuid():N}"[..20],
+ CreatorPlayerId = session.PlayerId,
+ CloningAllowed = true,
+ CurrentDataBlobName = sourceBlob,
+ });
+ db.RoomScenes.Add(new RoomSceneEntity
+ {
+ RoomId = sourceId,
+ Name = "Home",
+ OrderIndex = 0,
+ DataBlobName = sourceBlob,
+ });
+ await db.SaveChangesAsync();
+ }
+
+ var clone = await ReadJsonAsync(client, HttpMethod.Post, $"/rooms/{sourceId}/clone",
+ new FormUrlEncodedContent([new KeyValuePair("name", $"Copy{Guid.NewGuid():N}"[..20])]));
+ var cloneId = clone.GetProperty("RoomId").GetInt64();
+
+ await using (var scope = factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var room = await db.Rooms.FirstAsync(r => r.Id == cloneId);
+ var scenes = await db.RoomScenes.Where(s => s.RoomId == cloneId).ToListAsync();
+
+ Assert.False(room.CurrentDataBlobName == sourceBlob,
+ $"the clone's room row still points at the source's blob ({sourceBlob})");
+ foreach (var scene in scenes)
+ Assert.False(scene.DataBlobName == sourceBlob,
+ $"cloned sub-room {scene.OrderIndex} still points at the source's blob ({sourceBlob})");
+
+ // It must be a real copy, not just a cleared reference — the point
+ // is that the player's content comes along.
+ Assert.False(string.IsNullOrEmpty(room.CurrentDataBlobName),
+ "the clone has no blob at all; the source's data should have been copied, not dropped");
+ var (dstBucket, dstKey) = BlobRouter.Route(room.CurrentDataBlobName);
+ Assert.Equal(sourceBytes, await store.GetAsync(dstBucket, dstKey));
+ }
+ }
+
+ /// Minimal in-memory . The suite runs
+ /// with S3 unconfigured, so without this the clone's copy path short-circuits
+ /// and never gets exercised.
+ private sealed class InMemoryObjectStorage : IObjectStorage
+ {
+ private readonly Dictionary _objects = new(StringComparer.Ordinal);
+
+ public bool IsS3Configured => true;
+
+ public Task PutAsync(
+ string bucket, string key, byte[] bytes, string contentType, CancellationToken ct = default)
+ {
+ _objects[$"{bucket}/{key}"] = bytes;
+ return Task.FromResult((long)bytes.Length);
+ }
+
+ public Task GetAsync(string bucket, string key, CancellationToken ct = default) =>
+ Task.FromResult(_objects.TryGetValue($"{bucket}/{key}", out var bytes) ? bytes : null);
+
+ public Task ExistsAsync(string bucket, string key, CancellationToken ct = default) =>
+ Task.FromResult(_objects.ContainsKey($"{bucket}/{key}"));
+ }
+
+ private static async Task ReadJsonAsync(
+ HttpClient client, HttpMethod method, string path, HttpContent? content = null)
+ {
+ using var request = new HttpRequestMessage(method, path) { Content = content };
+ using var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode,
+ $"{method} {path} -> {(int)response.StatusCode}: {body}");
+ return JsonDocument.Parse(body).RootElement.Clone();
+ }
+
+ private HttpClient Client()
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://rooms.{_factory.ApexDomain}");
+ return client;
+ }
+}
diff --git a/DorkNet.Server.Tests/RoomConsumablesShopTests.cs b/DorkNet.Server.Tests/RoomConsumablesShopTests.cs
index 534f73e..b5a65eb 100644
--- a/DorkNet.Server.Tests/RoomConsumablesShopTests.cs
+++ b/DorkNet.Server.Tests/RoomConsumablesShopTests.cs
@@ -80,7 +80,11 @@ public sealed class RoomConsumablesShopTests : IClassFixture) on MJKFEHPIDME.
+ Assert.Equal(6, badPrice.GetProperty("BalanceUpdateResult").GetInt32()); // RequestedPriceDoesNotMatch
+ Assert.Equal(38, badPrice.GetProperty("OperationResult").GetProperty("Status").GetInt32());
var balanceBefore = badPrice.GetProperty("TokenBalanceResponse").GetProperty("Balance").GetInt64();
// ── Purchase (client stores NewConcurrencyCode locally) ──────────
@@ -102,7 +111,7 @@ public sealed class RoomConsumablesShopTests : IClassFixture
var firstCloneId = await CloneAndAssertAsync(
roomsClient, roomId, "FirstClone", pathPrefix: "/roomserver");
Assert.NotEqual(roomId, firstCloneId);
- await AssertBlobsAsync(firstCloneId, expectEmpty: true,
- "first-party template blob must not be copied onto the clone");
+ // A clone of a first-party template used to be left with NO blob, on
+ // the reasoning that its content is baked client geometry. That left
+ // the copy with nothing to load: the CDN synthesises a ~1.8 KB stub for
+ // a name it has nothing stored under, and the client fetches exactly
+ // that through GetRoomData ("/room/{blob}") while copying, then rejects
+ // it. Clones that carried real content joined fine.
+ //
+ // The content now comes across. Sharing the SOURCE's object is still
+ // wrong, but that is prevented by copying it under the clone's own name
+ // rather than by having no blob at all — see
+ // RoomCloneShapeTests.Clone_does_not_reference_the_source_rooms_blob,
+ // which runs with a real object store. This suite has none, so the
+ // copy deliberately falls back to keeping the reference and only the
+ // "has content" half is checkable here.
+ await AssertBlobsAsync(firstCloneId, expectEmpty: false,
+ "a clone of a first-party template must carry its content");
// Simulate the player saving MakerPen edits into their clone.
var editedBlob = $"room_{firstCloneId}_edit_{Guid.NewGuid():N}.dat";
diff --git a/DorkNet.Server.Tests/SchemaBootstrapParityTests.cs b/DorkNet.Server.Tests/SchemaBootstrapParityTests.cs
new file mode 100644
index 0000000..3060da9
--- /dev/null
+++ b/DorkNet.Server.Tests/SchemaBootstrapParityTests.cs
@@ -0,0 +1,271 @@
+using System.Text.RegularExpressions;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using DorkNet.Server.Data;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// Guards the two ways a column reaches a database that already exists, because
+/// getting either one wrong is invisible until a live server starts returning
+/// 500s.
+///
+/// Every other test in this suite runs against a FRESH database, where
+/// EnsureCreated builds the whole schema straight from the entity model —
+/// so every column always exists and none of this is exercised. Only an
+/// *existing* database can be missing a column, and that is exactly what took
+/// down club home, matchmake/dorm, the presence heartbeat and the chat inbox at
+/// once: three columns were added to the entity model with an EF migration, but
+/// the Postgres path never replays migrations, so a deployed database never got
+/// them.
+///
+/// The two mechanisms, and the invariant each test asserts:
+///
+///
+/// SQLite applies committed EF migrations via Database.Migrate().
+/// So the migration history alone must be able to build the current model.
+/// Postgres is EnsureCreated-only and NEVER replays
+/// migrations. A database created before a column existed only gets it from an
+/// idempotent Ensure* ALTER in 's
+/// DatabaseBootstrap. So anything added after the Initial migration must appear
+/// there too.
+///
+///
+public sealed class SchemaBootstrapParityTests
+{
+ /// Every table and column in the entity model must be reachable on
+ /// a database that already exists — via a committed migration, an
+ /// idempotent Ensure* patch, or both.
+ ///
+ /// That union is exactly what boot does: SQLite runs Database.Migrate()
+ /// and then the Ensure* helpers, and Postgres runs the helpers alone.
+ /// A property with NEITHER reaches only brand-new databases, and every
+ /// query touching it throws at runtime on a deployed server.
+ ///
+ /// The union is asserted rather than migrations alone because a large amount
+ /// of the schema legitimately predates this rule and lives only in the
+ /// helpers. Tightening that up is a separate cleanup; what must never happen
+ /// is a column covered by nothing.
+ [Fact]
+ public async Task Every_model_column_reaches_an_existing_database()
+ {
+ await using var connection = new SqliteConnection("DataSource=:memory:");
+ await connection.OpenAsync();
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(connection)
+ .Options;
+
+ await using var db = new DorkNetDbContext(options);
+ await db.Database.MigrateAsync();
+
+ var actual = await ReadSqliteSchemaAsync(connection);
+ var (patchedTables, patchedColumns) = ReadBootstrapPatches(postgresOnly: false);
+ var missing = new List();
+
+ foreach (var entity in db.Model.GetEntityTypes())
+ {
+ var table = entity.GetTableName();
+ if (string.IsNullOrEmpty(table)) continue;
+
+ var migrated = actual.TryGetValue(table, out var columns);
+ if (!migrated && !patchedTables.Contains(table))
+ {
+ missing.Add($"{table} (whole table)");
+ continue;
+ }
+ // A table the helpers create carries its own columns in the same
+ // CREATE, so only migrated tables need a per-column check.
+ if (!migrated) continue;
+
+ foreach (var property in entity.GetProperties())
+ {
+ var column = property.GetColumnName();
+ if (string.IsNullOrEmpty(column)) continue;
+ if (columns!.Contains(column)) continue;
+ if (patchedColumns.Contains($"{table}.{column}")) continue;
+ missing.Add($"{table}.{column}");
+ }
+ }
+
+ Assert.True(missing.Count == 0,
+ $"""
+ {missing.Count} model table(s)/column(s) are covered by NOTHING.
+
+ They exist on a freshly-created database (EnsureCreated builds the
+ whole model) but never appear on one that already exists, so a
+ deployed server throws "no such column" / "column does not exist"
+ on every request that touches them.
+
+ Add a migration AND an idempotent ALTER in an Ensure*Async helper in
+ DorkNet.Server/Startup/DatabaseBootstrap.cs for:
+
+ {string.Join(Environment.NewLine, missing.Order(StringComparer.Ordinal))}
+ """);
+ }
+
+ /// Every column added AFTER the Initial migration must also be
+ /// added by an idempotent Postgres ALTER in DatabaseBootstrap.
+ ///
+ /// Postgres never replays migrations, so a migration alone reaches only
+ /// brand-new databases. A production database created before the column
+ /// existed gets it from the Ensure* helpers or not at all — the exact hole
+ /// that broke matchmake/dorm (PrivateInstances.RoomCode),
+ /// club/home/me (Clubs.ClubChatEnabled) and the chat inbox
+ /// (ChatMessages.ModerationState).
+ [Fact]
+ public void Post_initial_columns_are_patched_for_existing_postgres_databases()
+ {
+ var migrations = FindRepoDirectory("DorkNet.Server", "Migrations");
+ var (patchedTables, patchedColumns) = ReadBootstrapPatches(postgresOnly: true);
+
+ // Anything AddColumn'd by a migration other than Initial post-dates the
+ // baseline, so an existing Postgres database will not have it.
+ var gaps = new List();
+ foreach (var file in Directory.GetFiles(migrations, "*.cs"))
+ {
+ var name = Path.GetFileNameWithoutExtension(file);
+ if (name.Contains("Designer", StringComparison.Ordinal)) continue;
+ if (name.Contains("Initial", StringComparison.Ordinal)) continue;
+ if (name.Contains("ModelSnapshot", StringComparison.Ordinal)) continue;
+
+ var text = File.ReadAllText(file);
+ foreach (Match m in Regex.Matches(text,
+ @"AddColumn<[^>]+>\(\s*name:\s*""(?\w+)""\s*,\s*table:\s*""(?
\w+)""",
+ RegexOptions.Singleline))
+ {
+ var table = m.Groups["table"].Value;
+ var column = m.Groups["column"].Value;
+ if (patchedTables.Contains(table)) continue; // whole table is created by a patch
+ if (patchedColumns.Contains($"{table}.{column}")) continue;
+ gaps.Add($"{table}.{column} (added by {name})");
+ }
+
+ foreach (Match m in Regex.Matches(text,
+ @"CreateTable\(\s*name:\s*""(?
\w+)""",
+ RegexOptions.Singleline))
+ {
+ var table = m.Groups["table"].Value;
+ if (!patchedTables.Contains(table))
+ gaps.Add($"{table} (whole table, created by {name})");
+ }
+ }
+
+ Assert.True(gaps.Count == 0,
+ $"""
+ {gaps.Count} schema change(s) reach new databases only.
+
+ The Postgres path is EnsureCreated-only and never replays
+ migrations, so a migration alone does NOT add these to a database
+ that already exists — a deployed server will throw
+ "column does not exist" on every request that touches them.
+
+ Add an idempotent ALTER/CREATE to an Ensure*Async helper in
+ DorkNet.Server/Startup/DatabaseBootstrap.cs for:
+
+ {string.Join(Environment.NewLine, gaps.Order(StringComparer.Ordinal))}
+ """);
+ }
+
+ /// Tables and columns the Ensure* helpers add by raw SQL.
+ ///
+ /// The helpers are private, so this reads their statements out of the source
+ /// rather than executing them. In that source the SQL sits inside verbatim
+ /// strings, so a quoted identifier appears as two literal quote characters
+ /// (""Playlists"").
+ ///
+ /// keys off IF NOT EXISTS: the
+ /// Postgres branch uses it, while the SQLite branch omits it and swallows
+ /// the duplicate-column error instead. So requiring it isolates the branch
+ /// that an existing Postgres database actually depends on.
+ private static (HashSet Tables, HashSet Columns) ReadBootstrapPatches(
+ bool postgresOnly)
+ {
+ var bootstrap = Path.Combine(
+ FindRepoDirectory("DorkNet.Server", "Startup"), "DatabaseBootstrap.cs");
+ Assert.True(File.Exists(bootstrap), $"missing {bootstrap}");
+ var sql = File.ReadAllText(bootstrap);
+
+ // The source mixes both C# string styles, so a quoted identifier shows
+ // up as either two quote characters (verbatim, @"... ""X"" ...") or one
+ // (raw, """... "X" ..."""). Accept either.
+ const string Q = "\"{1,2}";
+ var ifNotExists = postgresOnly ? @"IF\s+NOT\s+EXISTS\s+" : @"(?:IF\s+NOT\s+EXISTS\s+)?";
+
+ var columns = Regex
+ .Matches(sql,
+ $@"ALTER\s+TABLE\s+{Q}(?
\w+){Q}",
+ RegexOptions.IgnoreCase)
+ .Select(m => m.Groups["table"].Value)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ // Some helpers loop a column-name array through one interpolated ALTER
+ // (EnsureGameRewardColumnsAsync does). The statement alone only shows
+ // "{col}", so pair each such ALTER with the nearest preceding
+ // `new[] { "A", "B" }` and expand it.
+ foreach (Match alter in Regex.Matches(sql,
+ $@"ALTER\s+TABLE\s+{Q}(?
\w+){Q}\s+ADD\s+COLUMN\s+{ifNotExists}{Q}\{{(?\w+)\}}{Q}",
+ RegexOptions.IgnoreCase))
+ {
+ var loop = Regex.Matches(
+ sql[..alter.Index],
+ $@"foreach\s*\(\s*var\s+{Regex.Escape(alter.Groups["var"].Value)}\s+in\s+new\[\]\s*\{{(?[^}}]*)\}}")
+ .Cast()
+ .LastOrDefault();
+ if (loop is null) continue;
+
+ foreach (Match item in Regex.Matches(loop.Groups["items"].Value, "\"(?\\w+)\""))
+ columns.Add($"{alter.Groups["table"].Value}.{item.Groups["name"].Value}");
+ }
+
+ return (tables, columns);
+ }
+
+ private static async Task>> ReadSqliteSchemaAsync(
+ SqliteConnection connection)
+ {
+ var schema = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ var tables = new List();
+ await using (var cmd = connection.CreateCommand())
+ {
+ cmd.CommandText =
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';";
+ await using var reader = await cmd.ExecuteReaderAsync();
+ while (await reader.ReadAsync()) tables.Add(reader.GetString(0));
+ }
+
+ foreach (var table in tables)
+ {
+ var columns = new HashSet(StringComparer.OrdinalIgnoreCase);
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"PRAGMA table_info(\"{table}\");";
+ await using var reader = await cmd.ExecuteReaderAsync();
+ while (await reader.ReadAsync()) columns.Add(reader.GetString(1));
+ schema[table] = columns;
+ }
+
+ return schema;
+ }
+
+ /// Walk up from the test binaries to the repo root, then into the
+ /// requested source directory.
+ private static string FindRepoDirectory(params string[] segments)
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "DorkNet.sln")))
+ dir = dir.Parent;
+
+ Assert.NotNull(dir);
+ var path = Path.Combine(new[] { dir!.FullName }.Concat(segments).ToArray());
+ Assert.True(Directory.Exists(path), $"missing source directory {path}");
+ return path;
+ }
+}
diff --git a/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs b/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs
new file mode 100644
index 0000000..e801474
--- /dev/null
+++ b/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs
@@ -0,0 +1,164 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// Max players is a PER SUB-ROOM setting, adjustable from two places:
+///
+/// in-game PUT rooms/{id}/subrooms/{sub}/maxplayers (owner only — the
+/// "Max Player Count in This Subroom" slider)
+/// admin PUT api/admin/v1/rooms/{id}/subrooms/{sub}/maxplayers
+///
+/// Both write the same , so a change
+/// made in one place has to be visible from the other. That is the property
+/// worth pinning: two independent write paths onto one column drift silently,
+/// and the symptom (a slider that snaps back) looks like a client bug.
+///
+/// Kept distinct from the room's own ,
+/// which is what the ROOM advertises and what matchmaking hands to
+/// RoomInstance.MaxCapacity. Reading the room-level figure off sub-room 0
+/// would collapse the two settings into one.
+///
+public sealed class SubRoomMaxPlayersTests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public SubRoomMaxPlayersTests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public async Task Owner_and_admin_edit_the_same_per_subroom_value()
+ {
+ using var setup = Client("rooms");
+ var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+
+ var roomId = 9_700_000 + Random.Shared.Next(1, 99_999);
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
+
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = roomId,
+ Name = $"Caps{Guid.NewGuid():N}"[..18],
+ CreatorPlayerId = owner.PlayerId,
+ MaxCapacity = 12,
+ });
+ db.RoomScenes.Add(new RoomSceneEntity
+ { RoomId = roomId, Name = "Home", OrderIndex = 0, MaxPlayers = 8 });
+ db.RoomScenes.Add(new RoomSceneEntity
+ { RoomId = roomId, Name = "Arena", OrderIndex = 1, MaxPlayers = 8 });
+ await db.SaveChangesAsync();
+ }
+
+ // ── in-game: the owner's slider on sub-room 1 only ──────────────────
+ // Sent exactly as the client sends it. NLDBPDCNNCF.MNBPCGJNLNP(Int64,
+ // Int64, Int32) builds a BLNOGFGHIIF whose single JSON key is
+ // "maxPlayers" and issues it with verb 3 = PUT — NOT the form encoding
+ // most of its siblings use. Testing with a form would pass while the
+ // real client's body went unread.
+ using var ownerClient = Client("rooms", owner);
+ using var slider = await ownerClient.PutAsJsonAsync(
+ $"/rooms/{roomId}/subrooms/1/maxplayers", new { maxPlayers = 24 });
+ var sliderBody = await slider.Content.ReadAsStringAsync();
+ Assert.True(slider.IsSuccessStatusCode,
+ $"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
+
+ // The client deserialises the reply as FGCPNAACHIK — the room-details
+ // shape — so the slider's own response has to carry the new value.
+ var echoed = JsonDocument.Parse(sliderBody).RootElement
+ .GetProperty("SubRooms").EnumerateArray()
+ .First(e => e.GetProperty("SubRoomId").GetInt64() == 1);
+ Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
+
+ // ── admin: reads what the owner just set, then edits sub-room 0 ─────
+ using var adminClient = Client("admin", admin);
+ var listed = await GetJsonAsync(adminClient, $"/api/admin/v1/rooms/{roomId}/subrooms");
+ var subRooms = listed.EnumerateArray()
+ .ToDictionary(e => e.GetProperty("subRoomId").GetInt32(),
+ e => e.GetProperty("maxPlayers").GetInt32());
+
+ Assert.Equal(24, subRooms[1]);
+ Assert.Equal(8, subRooms[0]);
+
+ using var adminSet = await adminClient.PutAsJsonAsync(
+ $"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 40 });
+ Assert.True(adminSet.IsSuccessStatusCode,
+ $"admin PUT maxplayers -> {(int)adminSet.StatusCode}: {await adminSet.Content.ReadAsStringAsync()}");
+
+ // ── the room's own details carry each sub-room's own figure ─────────
+ var details = await GetJsonAsync(ownerClient, $"/rooms/{roomId}");
+ var wire = details.GetProperty("SubRooms").EnumerateArray()
+ .ToDictionary(e => e.GetProperty("SubRoomId").GetInt64(),
+ e => e.GetProperty("MaxPlayers").GetInt32());
+
+ Assert.Equal(40, wire[0]);
+ Assert.Equal(24, wire[1]);
+
+ // The room-level figure is the room's advertised capacity, NOT a copy
+ // of sub-room 0 — otherwise the admin edit above would have moved it.
+ Assert.Equal(12, details.GetProperty("MaxPlayers").GetInt32());
+ }
+
+ [Fact]
+ public async Task Admin_cap_is_clamped_to_a_joinable_range()
+ {
+ using var setup = Client("rooms");
+ var owner = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var admin = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+
+ var roomId = 9_800_000 + Random.Shared.Next(1, 99_999);
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Players.FirstAsync(p => p.Id == admin.PlayerId)).IsAdmin = true;
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = roomId,
+ Name = $"Clamp{Guid.NewGuid():N}"[..18],
+ CreatorPlayerId = owner.PlayerId,
+ });
+ db.RoomScenes.Add(new RoomSceneEntity
+ { RoomId = roomId, Name = "Home", OrderIndex = 0, MaxPlayers = 8 });
+ await db.SaveChangesAsync();
+ }
+
+ using var adminClient = Client("admin", admin);
+
+ // 0 would make the sub-room unjoinable.
+ using var tooLow = await adminClient.PutAsJsonAsync(
+ $"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 0 });
+ var low = JsonDocument.Parse(await tooLow.Content.ReadAsStringAsync()).RootElement;
+ Assert.Equal(1, low.GetProperty("maxPlayers").GetInt32());
+
+ using var tooHigh = await adminClient.PutAsJsonAsync(
+ $"/api/admin/v1/rooms/{roomId}/subrooms/0/maxplayers", new { MaxPlayers = 9999 });
+ var high = JsonDocument.Parse(await tooHigh.Content.ReadAsStringAsync()).RootElement;
+ Assert.Equal(80, high.GetProperty("maxPlayers").GetInt32());
+ }
+
+ private static async Task GetJsonAsync(HttpClient client, string path)
+ {
+ using var response = await client.GetAsync(path);
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
+ return JsonDocument.Parse(body).RootElement.Clone();
+ }
+
+ private HttpClient Client(string host, GameClientSession? session = null)
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
+ if (session is not null)
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return client;
+ }
+}
diff --git a/DorkNet.Server.Tests/TestCaseGitHubIssueTests.cs b/DorkNet.Server.Tests/TestCaseGitHubIssueTests.cs
new file mode 100644
index 0000000..020da20
--- /dev/null
+++ b/DorkNet.Server.Tests/TestCaseGitHubIssueTests.cs
@@ -0,0 +1,251 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using DorkNet.Server.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.AspNetCore.Mvc.Testing;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// QA test cases linked to GitHub issues.
+///
+/// The link lives in — the field Rec
+/// Room's own tooling used for the bug filed against a failing case — so no
+/// column is added and the Postgres "migrations never replay" trap is avoided
+/// entirely.
+///
+/// GitHub itself is faked. These tests are about our behaviour around it:
+/// filing once rather than per sweep, following issue state in the one
+/// direction that is safe, and degrading rather than 500ing when no token is
+/// configured.
+///
+public sealed class TestCaseGitHubIssueTests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public TestCaseGitHubIssueTests(DorkNetServerFactory factory) => _factory = factory;
+
+ private const int StatusClaimed = 1;
+ private const int StatusFailed = 2;
+ private const int StatusPassed = 3;
+
+ [Fact]
+ public async Task Filing_is_idempotent_and_stores_the_issue_url()
+ {
+ var github = new FakeGitHub();
+ using var factory = WithGitHub(github);
+ using var client = await AdminClientAsync(factory);
+ var caseId = await SeedCaseAsync(factory, StatusFailed);
+
+ using var first = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
+ var firstBody = await first.Content.ReadAsStringAsync();
+ Assert.True(first.IsSuccessStatusCode, $"first file -> {(int)first.StatusCode}: {firstBody}");
+ Assert.True(JsonDocument.Parse(firstBody).RootElement.GetProperty("created").GetBoolean());
+
+ // The link must be persisted, and in the field the admin UI reads.
+ await using (var scope = factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var tc = await db.TestCases.SingleAsync(c => c.Id == caseId);
+ Assert.Contains("/issues/", tc.JiraBugUrl);
+ }
+
+ // Re-running a sweep over a failing pass must not file the bug again.
+ // Measured as a DELTA rather than an absolute count: the fake is shared
+ // with whatever else the server does during the test, and the property
+ // under test is "this call filed nothing", not "nothing was ever filed".
+ var filedBefore = github.Created;
+ using var second = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
+ var secondBody = await second.Content.ReadAsStringAsync();
+ Assert.True(second.IsSuccessStatusCode, $"second file -> {(int)second.StatusCode}: {secondBody}");
+ Assert.False(JsonDocument.Parse(secondBody).RootElement.GetProperty("created").GetBoolean(),
+ "filing twice created a duplicate issue");
+ Assert.Equal(filedBefore, github.Created);
+ }
+
+ [Fact]
+ public async Task A_closed_issue_marks_the_failing_case_passed()
+ {
+ var github = new FakeGitHub();
+ using var factory = WithGitHub(github);
+ using var client = await AdminClientAsync(factory);
+ var caseId = await SeedCaseAsync(factory, StatusFailed);
+
+ using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
+ Assert.True(filed.IsSuccessStatusCode);
+
+ github.CloseAll();
+ using var sync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
+ Assert.True(sync.IsSuccessStatusCode, $"sync -> {(int)sync.StatusCode}");
+
+ await using var scope = factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var tc = await db.TestCases.SingleAsync(c => c.Id == caseId);
+
+ // A closed issue means the bug is fixed, so the case that was failing
+ // on it passes.
+ Assert.Equal(StatusPassed, tc.Status);
+ }
+
+ [Fact]
+ public async Task A_reopened_issue_marks_the_case_failed_again()
+ {
+ var github = new FakeGitHub();
+ using var factory = WithGitHub(github);
+ using var client = await AdminClientAsync(factory);
+ var caseId = await SeedCaseAsync(factory, StatusFailed);
+
+ using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
+ Assert.True(filed.IsSuccessStatusCode);
+
+ github.CloseAll();
+ using var closeSync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
+ Assert.True(closeSync.IsSuccessStatusCode);
+
+ github.ReopenAll();
+ using var reopenSync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
+ Assert.True(reopenSync.IsSuccessStatusCode);
+
+ await using var scope = factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ Assert.Equal(StatusFailed, (await db.TestCases.SingleAsync(c => c.Id == caseId)).Status);
+ }
+
+ [Fact]
+ public async Task A_sweep_does_not_stomp_a_tester_mid_claim()
+ {
+ var github = new FakeGitHub();
+ using var factory = WithGitHub(github);
+ using var client = await AdminClientAsync(factory);
+ var caseId = await SeedCaseAsync(factory, StatusFailed);
+
+ using var filed = await client.PostAsync($"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
+ Assert.True(filed.IsSuccessStatusCode);
+
+ // Someone picks the case up while the issue is still open.
+ await using (var scope = factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.TestCases.SingleAsync(c => c.Id == caseId)).Status = StatusClaimed;
+ await db.SaveChangesAsync();
+ }
+
+ using var sync = await client.PostAsync("/api/testcasemanagement/v1/issues/sync", null);
+ Assert.True(sync.IsSuccessStatusCode);
+
+ await using var check = factory.Services.CreateAsyncScope();
+ var db2 = check.ServiceProvider.GetRequiredService();
+ var tc = await db2.TestCases.SingleAsync(c => c.Id == caseId);
+
+ // An open issue against a claimed case says nothing new; flipping it to
+ // Failed would yank it out from under whoever is testing it. Only
+ // Failed<->Passed are the reconciler's to move.
+ Assert.Equal(StatusClaimed, tc.Status);
+ }
+
+ [Fact]
+ public async Task Without_a_token_the_endpoint_says_so_instead_of_failing()
+ {
+ // The default fixture has no GitHub configuration, which is what an
+ // ordinary deployment looks like.
+ using var client = await AdminClientAsync(_factory);
+ var caseId = await SeedCaseAsync(_factory, StatusFailed);
+
+ using var response = await client.PostAsync(
+ $"/api/testcasemanagement/v1/testcase/{caseId}/issue", null);
+
+ Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
+ }
+
+ private WebApplicationFactory WithGitHub(IGitHubIssues github) =>
+ _factory.WithWebHostBuilder(builder =>
+ builder.ConfigureServices(services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton(github);
+ }));
+
+ private async Task AdminClientAsync(WebApplicationFactory factory)
+ {
+ var client = factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
+ var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
+
+ await using (var scope = factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ (await db.Players.FirstAsync(p => p.Id == session.PlayerId)).IsAdmin = true;
+ await db.SaveChangesAsync();
+ }
+
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return client;
+ }
+
+ private static async Task SeedCaseAsync(WebApplicationFactory factory, int status)
+ {
+ var id = $"tc-{Guid.NewGuid():N}";
+ await using var scope = factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.TestCases.Add(new TestCaseEntity
+ {
+ Id = id,
+ Key = $"QA-{Random.Shared.Next(1000, 9999)}",
+ Title = "Dorm fails to load after cloning a room",
+ Description = "Clone RecCenter, then return to the dorm.",
+ RoomName = "Dorm",
+ Status = status,
+ TagsJson = """["dorm","regression"]""",
+ CommentsJson = """["1811750|2026-07-28T08:00:00Z|Reproduced twice"]""",
+ });
+ await db.SaveChangesAsync();
+ return id;
+ }
+
+ /// An in-memory GitHub. Records what was filed so the tests can
+ /// assert we did not file twice, and lets them flip issue state.
+ private sealed class FakeGitHub : IGitHubIssues
+ {
+ private readonly Dictionary _issues = [];
+ private int _next = 1;
+
+ public bool IsConfigured => true;
+
+ public string Repository => "DorkSquadRR/DorkNet";
+
+ public int Created { get; private set; }
+
+ public Task CreateAsync(
+ string title, string body, IEnumerable labels, CancellationToken ct = default)
+ {
+ var number = _next++;
+ var issue = new GitHubIssue(
+ number, $"https://github.com/{Repository}/issues/{number}", "open", title);
+ _issues[number] = issue;
+ Created++;
+ return Task.FromResult(issue);
+ }
+
+ public Task GetAsync(int number, CancellationToken ct = default) =>
+ Task.FromResult(_issues.TryGetValue(number, out var issue) ? issue : null);
+
+ public Task CommentAsync(int number, string body, CancellationToken ct = default) =>
+ Task.FromResult(_issues.ContainsKey(number));
+
+ public void CloseAll() => SetState("closed");
+
+ public void ReopenAll() => SetState("open");
+
+ private void SetState(string state)
+ {
+ foreach (var number in _issues.Keys.ToList())
+ _issues[number] = _issues[number] with { State = state };
+ }
+ }
+}
diff --git a/DorkNet.Server.Tests/data/client-dto-keys-2023.json b/DorkNet.Server.Tests/data/client-dto-keys-2023.json
new file mode 100644
index 0000000..513e7d6
--- /dev/null
+++ b/DorkNet.Server.Tests/data/client-dto-keys-2023.json
@@ -0,0 +1,2815 @@
+{
+ "AALDCAANEMM": [
+ "CurrencyPurchaseOfferId",
+ "CurrencyId",
+ "Order",
+ "Name",
+ "CurrencyAmount",
+ "Price",
+ "ModifiedAt"
+ ],
+ "ABJLKAFOPMP": [
+ "GiftDropIds",
+ "Message",
+ "SubscriptionPurchase"
+ ],
+ "ACKNGEEHHAE": [
+ "RoomId",
+ "SubRoomId",
+ "ClubId",
+ "Name",
+ "Description",
+ "Tags",
+ "ImageName",
+ "StartTime",
+ "EndTime",
+ "Accessibility",
+ "IsMultiInstance",
+ "SupportMultiInstanceRoomChat",
+ "DefaultBroadcastPermissions",
+ "CanRequestBroadcastPermissions"
+ ],
+ "ADAFNGNADFH": [
+ "InviteeAccountId",
+ "CreatedAt",
+ "VerifiedAt"
+ ],
+ "ADDGMGGOLHM": [
+ "BlobName",
+ "Title",
+ "Description",
+ "ThumbnailBlobName",
+ "SourceUrl"
+ ],
+ "ADMGGPDGKCE": [
+ "Msg",
+ "Id"
+ ],
+ "AECJMONHBHM": [
+ "ClubId",
+ "LastReadAnnouncementId",
+ "Announcements"
+ ],
+ "AEGADMDIKNE": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "AEGCKMKICFG": [
+ "AccountId",
+ "UserName",
+ "DisplayName",
+ "DisplayEmoji",
+ "ProfileImage",
+ "BannerImage",
+ "TreatAsJunior",
+ "HasBirthday",
+ "PersonalPronouns",
+ "IdentityFlags",
+ "CreatedAt",
+ "IsJunior"
+ ],
+ "AEHFGHPLNDO": [
+ "AccountId",
+ "LastVisitedAt"
+ ],
+ "AFHCKGCJNFM": [
+ "Msg",
+ "Id"
+ ],
+ "AFJDLEFKNFP": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "AHJKILCAGML": [
+ "ChallengeId",
+ "Name",
+ "Config",
+ "Description",
+ "Tooltip",
+ "Complete"
+ ],
+ "AIEJFLGKIGN": [
+ "BalanceUpdates",
+ "Balance",
+ "CurrencyType",
+ "Platform"
+ ],
+ "AJEDJLBJLFA": [
+ "AccountId",
+ "ClubId",
+ "MembershipType",
+ "CreatedAt"
+ ],
+ "AKDFPANIAFL": [
+ "AnnouncementId",
+ "AnnouncementType",
+ "Title",
+ "Body",
+ "ImageName",
+ "LinkType",
+ "LinkName",
+ "LinkButtonLabel",
+ "LinkUri",
+ "Platform",
+ "CreatedAt"
+ ],
+ "AKKPACEJJFK": [
+ "Msg",
+ "Id"
+ ],
+ "ALGKCAKCFAJ": [
+ "BalanceUpdates",
+ "Balance",
+ "CurrencyType",
+ "Platform"
+ ],
+ "ALJPHDEAHBK": [
+ "Result",
+ "Tags"
+ ],
+ "AMPFGJPKHOD": [
+ "Device_code",
+ "User_code",
+ "Verification_uri",
+ "Verification_uri_complete",
+ "Expires_in",
+ "Interval"
+ ],
+ "ANHGLIFGACE": [
+ "RoomKeyId",
+ "ReplicationId",
+ "RoomId",
+ "Name",
+ "Description",
+ "Price",
+ "PurchaseCurrencyId",
+ "CreatedAt",
+ "ImageName"
+ ],
+ "ANNOJCNMHIG": [
+ "CustomAvatarItemId",
+ "CreatorAccountId",
+ "Name",
+ "Description",
+ "Price",
+ "Accessibility",
+ "IsFeatured",
+ "BaseAvatarItemId",
+ "BaseAvatarItemColor",
+ "DesignFilename",
+ "ThumbnailImageFilename",
+ "CreatedAt",
+ "ModifiedAt"
+ ],
+ "ANPJKOOEEBB": [
+ "Identifier",
+ "Genuine",
+ "GenuineCheckAvailable"
+ ],
+ "AOAACMFGAGK": [
+ "ChallengeMapId",
+ "ChallengeThemeString",
+ "Challenges",
+ "Gift",
+ "FallbackGiftName",
+ "StartAt",
+ "EndAt",
+ "ServerTime",
+ "CompletedRequired"
+ ],
+ "APOJHADGCNA": [
+ "Ids",
+ "CreatedAts",
+ "ConsumableItemDesc",
+ "Count",
+ "InitialCount",
+ "IsActive",
+ "ActiveDurationMinutes",
+ "IsTransferable"
+ ],
+ "BAEBDMPPKKE": [
+ "Take",
+ "ContinuationToken",
+ "InjectedFeedItems",
+ "FeedInstanceId"
+ ],
+ "BBACJMOPFPE": [
+ "ProgressionEventRewardId",
+ "GiftDropId",
+ "ImageName",
+ "Xp",
+ "RewardIndex",
+ "IsBonus"
+ ],
+ "BBEFAELEANL": [
+ "BalanceUpdates",
+ "Balance",
+ "CurrencyType",
+ "Platform"
+ ],
+ "BBFGGPEMLLE": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "BBMJLKMGFGA": [
+ "Msg",
+ "Id"
+ ],
+ "BCEJDGGBBHC": [
+ "Price",
+ "CurrencyId"
+ ],
+ "BCMOCNHAJCL": [
+ "Status",
+ "Consumable"
+ ],
+ "BGFBJCIOIFP": [
+ "SkuId",
+ "Name",
+ "Description",
+ "ImageName",
+ "Price",
+ "OculusSkuId",
+ "AppleProductId",
+ "PsnProductLabel",
+ "XboxProductId",
+ "XboxStoreId",
+ "GooglePlaySkuId",
+ "PicoSkuId",
+ "IsSingleUse",
+ "ShouldAppearInTokenStore",
+ "Data"
+ ],
+ "BIMHOENEGII": [
+ "Group",
+ "IsCompleted",
+ "ClearedAt"
+ ],
+ "BJHOKILBIML": [
+ "FeedItemType",
+ "FeedItemId"
+ ],
+ "BKCOBJILFEI": [
+ "KeepsakeFeatureEnabled",
+ "KeepsakeRoomLimit",
+ "SocialXpBoostEnabled"
+ ],
+ "BKFOGBGPLFP": [
+ "SavedImageId",
+ "ImageName",
+ "Username",
+ "PlayerId",
+ "RoomName",
+ "RoomId"
+ ],
+ "BLADEIPEPOH": [
+ "SavedImageId",
+ "ImageName",
+ "PlayerId",
+ "RoomId",
+ "PlayerEventId",
+ "ClubId",
+ "Description",
+ "Accessibility",
+ "AccessibilityLocked",
+ "SavedImageType",
+ "CreatedAt",
+ "CheerCount",
+ "CommentCount"
+ ],
+ "BLIBJIHOENF": [
+ "ClubId",
+ "Name",
+ "Description",
+ "MainImageName",
+ "State",
+ "CreatorAccountId",
+ "Category",
+ "Visibility",
+ "Joinability",
+ "AllowJuniors",
+ "MinLevel",
+ "MemberCount",
+ "IsRRO",
+ "ClubChatEnabled",
+ "ClubhouseRoomId",
+ "ClubType"
+ ],
+ "BLPIIBBPOJM": [
+ "Action",
+ "Reason",
+ "Error"
+ ],
+ "BLPNLMALDAC": [
+ "AccountId",
+ "Bio"
+ ],
+ "BPAPIKPFDAH": [
+ "Difficulty",
+ "EN_US"
+ ],
+ "BPHCOIBNCDP": [
+ "ClubId",
+ "Type",
+ "EditDetails",
+ "ApproveMember",
+ "CreateEvent",
+ "PostAnnouncement",
+ "EditPermissionSettings",
+ "BanUnban"
+ ],
+ "CAAJPHFCIPK": [
+ "ReportCategory",
+ "Reason"
+ ],
+ "CAEHDIMCGLD": [
+ "Msg",
+ "Id"
+ ],
+ "CAKCKAMAAPP": [
+ "ClubId",
+ "LastAnnouncementId",
+ "LastReadAnnouncementId",
+ "Announcements"
+ ],
+ "CBKDKACJDOF": [
+ "ChatMessageId",
+ "ChatThreadId",
+ "SenderPlayerId",
+ "TimeSent",
+ "Contents",
+ "ModerationState"
+ ],
+ "CBNEDELBFAJ": [
+ "Clubs",
+ "TotalClubs",
+ "ContinuationToken"
+ ],
+ "CCAGOJFGLLO": [
+ "FeedItemType",
+ "FeedItemId",
+ "FeedAlgorithmVersion"
+ ],
+ "CCKEEAMMHPJ": [
+ "UnityAssetId",
+ "CreatedByAccountId",
+ "BakedUnityAssets"
+ ],
+ "CDEDNEPPPND": [
+ "LevelProgressionMaps",
+ "DailyObjectives",
+ "ServerMaintenance",
+ "AutoMicMutingConfig",
+ "StorefrontConfig",
+ "RoomKeyConfig",
+ "RoomCurrencyConfig",
+ "ShareBaseUrl"
+ ],
+ "CEAMINNJFGO": [
+ "Left",
+ "Right",
+ "Spine",
+ "Pelvis",
+ "Root",
+ "Torso",
+ "Body",
+ "Hips",
+ "Neck",
+ "Chest",
+ "Head",
+ "Arm",
+ "Hand",
+ "Wrist",
+ "Elbow",
+ "Palm",
+ "Leg",
+ "Thigh",
+ "Calf",
+ "Femur",
+ "Knee",
+ "Foot",
+ "Ankle",
+ "Hip",
+ "Tail",
+ "Eye",
+ "Dummy",
+ "Collar",
+ "Clavicle",
+ "Finger",
+ "Index",
+ "Mid",
+ "Pinky",
+ "Thumb",
+ "Adjust",
+ "Twist",
+ "Toe",
+ "Lid",
+ "Brow",
+ "Lash"
+ ],
+ "CENEMCMGDKG": [
+ "Tags",
+ "PlayerEventId",
+ "CreatorPlayerId",
+ "RoomId",
+ "SubRoomId",
+ "ClubId",
+ "Name",
+ "Description",
+ "ImageName",
+ "StartTime",
+ "EndTime",
+ "AttendeeCount",
+ "Accessibility",
+ "IsMultiInstance",
+ "SupportMultiInstanceRoomChat",
+ "DefaultBroadcastPermissions",
+ "CanRequestBroadcastPermissions",
+ "BroadcastingRoomInstanceId"
+ ],
+ "CGACOKHBJCB": [
+ "Msg",
+ "Id"
+ ],
+ "CGCKIMIEHNM": [
+ "Name",
+ "Description",
+ "ImageName",
+ "WarningMask",
+ "CustomWarning",
+ "CreatorAccountId",
+ "State",
+ "Accessibility",
+ "SupportsLevelVoting",
+ "IsRRO",
+ "SupportsScreens",
+ "SupportsWalkVR",
+ "SupportsTeleportVR",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile",
+ "SupportsJuniors",
+ "MinLevel",
+ "CreatedAt",
+ "Stats",
+ "RankingContext"
+ ],
+ "CHEMHPGCJBM": [
+ "PlayerId",
+ "Score",
+ "Rank"
+ ],
+ "CHKOLCGMCKJ": [
+ "ReportBudget",
+ "FilterType",
+ "SampleRate",
+ "LogLineCount",
+ "CaptureNativeCrashes",
+ "ANRThresholdMs",
+ "MessageCount",
+ "MessageRegex",
+ "VersionRegex"
+ ],
+ "CICAKEDCJLA": [
+ "Email",
+ "Phone",
+ "Birthday",
+ "JuniorState",
+ "ParentAccountId",
+ "AvailableUsernameChanges",
+ "AccountId",
+ "UserName",
+ "DisplayName",
+ "DisplayEmoji",
+ "ProfileImage",
+ "BannerImage",
+ "TreatAsJunior",
+ "HasBirthday",
+ "PersonalPronouns",
+ "IdentityFlags",
+ "CreatedAt",
+ "IsJunior"
+ ],
+ "CICEHLNDLDE": [
+ "RoomConsumableId",
+ "AccountId",
+ "Count",
+ "ConcurrencyCode",
+ "ModifiedAt",
+ "Consumable"
+ ],
+ "CIKAJCJMOFJ": [
+ "MembershipRequestId",
+ "AccountId",
+ "InviterAccountId",
+ "ClubId",
+ "MembershipType",
+ "MembershipStatus",
+ "CreatedAt"
+ ],
+ "CJPKAAJBLAJ": [
+ "Id",
+ "Name",
+ "Description",
+ "StartDate",
+ "EndDate",
+ "WasManuallyClosed",
+ "TestCases",
+ "Tags",
+ "NumTestCases",
+ "NumPassedTestCases",
+ "NumFailedTestCases"
+ ],
+ "CLHBPPNOLHP": [
+ "Version",
+ "ButtonNumber",
+ "Override",
+ "CustomRoomName",
+ "CustomTitle",
+ "CustomDescription",
+ "DefaultRoomName",
+ "DefaultTitle",
+ "DefaultDescription"
+ ],
+ "CLIANCNHNIE": [
+ "AvatarItemType",
+ "AvatarItemDesc",
+ "FriendlyName",
+ "Tooltip",
+ "Rarity",
+ "TagList",
+ "AvatarItemId",
+ "IsBaseAvatarItem"
+ ],
+ "CMBHBADOOAF": [
+ "Msg",
+ "Id"
+ ],
+ "CMHEJKENPBH": [
+ "TriggerType",
+ "TriggerId",
+ "TriggeredAt"
+ ],
+ "CMLNDGCIPIH": [
+ "Index",
+ "Group",
+ "Progress",
+ "VisualProgress",
+ "IsCompleted",
+ "HasClaimedReward"
+ ],
+ "CNFFPECMOHE": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "CNNAFLNKDCL": [
+ "AccountId",
+ "KeepsakeInstanceId",
+ "CollectedAt"
+ ],
+ "CNNEAFEIMPP": [
+ "Msg",
+ "Id"
+ ],
+ "COIELAHKALG": [
+ "Enabled",
+ "Key",
+ "Region"
+ ],
+ "DCECPNPOEFG": [
+ "Msg",
+ "Id"
+ ],
+ "DDDLCGDEPNH": [
+ "PlayerEventResponseId",
+ "PlayerEventId",
+ "PlayerId",
+ "CreatedAt",
+ "Type"
+ ],
+ "DDFBPFOBIAO": [
+ "ImageName",
+ "Accessibility"
+ ],
+ "DDFNNBDLBKF": [
+ "AccountId",
+ "ClubId",
+ "SubscriberCount"
+ ],
+ "DEIDIGKKLHE": [
+ "Results",
+ "TotalResults"
+ ],
+ "DFKDBJMNKCO": [
+ "Event",
+ "NewSubscription"
+ ],
+ "DHAPGKPJDKH": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "DHMKNFOLCGM": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "DICIOFKDIEE": [
+ "Season",
+ "Name",
+ "StartAt",
+ "EndAt",
+ "CurrencyType",
+ "EliteUpgrade",
+ "Tiers",
+ "PersonalDetails",
+ "StorefrontType",
+ "NextUpdate",
+ "NewUntil"
+ ],
+ "DIEEOOKELHG": [
+ "AccountId",
+ "IsRecentHistoryVisible"
+ ],
+ "DILNAKMDJOF": [
+ "RoomId",
+ "SubRoomId",
+ "KeepsakeCategory"
+ ],
+ "DJCLNEEBAJP": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "DJFEOAGKKAK": [
+ "FreshnessSeconds",
+ "Url"
+ ],
+ "DJFIAOIFLDI": [
+ "Memberships",
+ "TotalMemberships",
+ "ContinuationToken"
+ ],
+ "DJHIABKJLBD": [
+ "RoomInstanceId",
+ "PhotonAccessToken",
+ "Permissions"
+ ],
+ "DKHBLLDMDHB": [
+ "Msg",
+ "Id"
+ ],
+ "DKMBKPLABAG": [
+ "Type",
+ "Score"
+ ],
+ "DKNKIEOODHK": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "DLJCLHBGADL": [
+ "Msg",
+ "Id"
+ ],
+ "DNBAAJILMPE": [
+ "RoomId",
+ "ClubId",
+ "PlayerCount"
+ ],
+ "DOINHLNFOCP": [
+ "Msg",
+ "Id"
+ ],
+ "DOJEJNECAAI": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "DOMOBFDIAJA": [
+ "AccountId",
+ "CheeredAt"
+ ],
+ "DPHFOPBAAPN": [
+ "PlayerId",
+ "StatChannel",
+ "RoomId",
+ "FilterType",
+ "SortAscending"
+ ],
+ "DPIECEMDKPL": [
+ "CreatorPlayerId",
+ "Data",
+ "IsValid"
+ ],
+ "DPKHIGPMCIP": [
+ "InviteId",
+ "Name"
+ ],
+ "DPNLANKOHON": [
+ "AccountId",
+ "CurrencyId",
+ "Balance",
+ "ModifiedAt"
+ ],
+ "DatatypeImplementation": [
+ "ENTITIES",
+ "ENTITY",
+ "ID",
+ "IDREF",
+ "IDREFS",
+ "NMTOKEN",
+ "NMTOKENS",
+ "NOTATION"
+ ],
+ "EACKMEGHIGM": [
+ "AccountId",
+ "ClubId",
+ "CreatedAt"
+ ],
+ "ECEBBLBCFKO": [
+ "AccountId",
+ "BannedByAccountId",
+ "BanStartTime"
+ ],
+ "ECHJAKNMEEC": [
+ "KeepsakeRoomListId",
+ "ProgressionEventId",
+ "UnlockItemAvatarItemId",
+ "UnlockItemGiftDropId",
+ "UnlockItemLockDurationTicks",
+ "RoomUnlockStartOffsetTicks",
+ "RoomUnlockIntervalTicks",
+ "RoomUnlockBatchSize",
+ "RoomType",
+ "KeepsakeRooms"
+ ],
+ "ECIINMENOGP": [
+ "BalanceAddType",
+ "Delta",
+ "Balance",
+ "Platform",
+ "CurrencyType"
+ ],
+ "EDMKBBPGJAB": [
+ "Balance",
+ "RoomKeyResponse"
+ ],
+ "EEAAGIJHLOJ": [
+ "PlayerEvent",
+ "Result",
+ "TagModifyResult"
+ ],
+ "EHFKGCIDOFL": [
+ "Results",
+ "TotalResults"
+ ],
+ "EHKEOCKBCDH": [
+ "Items",
+ "FeedInstanceId",
+ "FeedContext",
+ "FeedAlgorithmVersion"
+ ],
+ "EIFIAMHKFAG": [
+ "PurchaseReminderId",
+ "SkuId",
+ "TokenPrice",
+ "Title",
+ "Description",
+ "ImageName",
+ "EndDate",
+ "BonusGiftDrops"
+ ],
+ "EJEJHJPCBOE": [
+ "KeepsakeRoomId",
+ "RoomId",
+ "KeepsakeRoomListId",
+ "Type",
+ "Order"
+ ],
+ "EJIHDJDKKEC": [
+ "AccountId",
+ "RoomKeyId"
+ ],
+ "EJNFJPNOFFO": [
+ "Msg",
+ "Id"
+ ],
+ "EKEFFNBIOHJ": [
+ "ChatThreadId",
+ "LastReadMessageId",
+ "Messages",
+ "LatestMessage",
+ "PlayerIds",
+ "ChatThreadName",
+ "SnoozedUntil",
+ "IsFavorited",
+ "ClubId"
+ ],
+ "EKHJKDNHOPB": [
+ "OutfitSelections",
+ "OutfitSelectionsV2",
+ "FaceFeatures",
+ "SkinColor",
+ "HairColor",
+ "CustomAvatarItems"
+ ],
+ "EKMLNNNABAD": [
+ "UnityAssetId",
+ "RoomData",
+ "SubRoomData",
+ "InventionUsage",
+ "PersistenceVersion",
+ "Description",
+ "AutoPublish"
+ ],
+ "EMBCEDNHFLB": [
+ "GiftDropId",
+ "FriendlyName",
+ "Tooltip",
+ "TagList",
+ "ConsumableItemDesc",
+ "AvatarItemDesc",
+ "AvatarItemType",
+ "EquipmentPrefabName",
+ "EquipmentModificationGuid",
+ "IsQuery",
+ "Unique",
+ "SubscribersOnly",
+ "Rarity",
+ "AvatarItemId",
+ "CurrencyType",
+ "Currency",
+ "Context",
+ "ItemSetId",
+ "ItemSetFriendlyName"
+ ],
+ "EMJAJEHCIFN": [
+ "MarketingEmails",
+ "InviteEmails",
+ "GameClientTextInvites",
+ "FriendRequestEmails",
+ "CreatorPublishedNewRoomEmails",
+ "MyRoomSummaryEmails",
+ "NewPhotoTagEmails",
+ "PlayerEventInviteEmails",
+ "PlayerEventReminderEmails"
+ ],
+ "ENJDEONHNMO": [
+ "Created",
+ "Responses"
+ ],
+ "EOFEKNIHNNC": [
+ "PlayerEventId",
+ "BroadcastRoomInstanceId"
+ ],
+ "EOILOIMIJLJ": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "EOIMIBBJBCB": [
+ "ImageName",
+ "Title",
+ "Subtitle"
+ ],
+ "EPPPACFECMH": [
+ "Type",
+ "Reference"
+ ],
+ "EncodingTable": [
+ "Big5",
+ "GB2312",
+ "IBM850",
+ "IBM852",
+ "IBM857",
+ "IBM861",
+ "IBM869"
+ ],
+ "FAMGNALGHPN": [
+ "Msg",
+ "Id"
+ ],
+ "FBNOJCDDBJK": [
+ "XPAwardStrings",
+ "TotalXPAwarded",
+ "NewProgress"
+ ],
+ "FCIBLPCOODP": [
+ "RoomConsumableId",
+ "RoomId",
+ "Name",
+ "Description",
+ "ImageName",
+ "Price",
+ "PurchaseCurrencyId",
+ "ModifiedAt"
+ ],
+ "FCJNIBGKHDM": [
+ "ClubId",
+ "MembershipType"
+ ],
+ "FDPFICCCJNM": [
+ "Msg",
+ "Id"
+ ],
+ "FEJOMKJBOOE": [
+ "RoomId",
+ "EarningsDistributionMapping",
+ "EarningsDistributionMethod"
+ ],
+ "FIFFMMLKKCJ": [
+ "PlayerId",
+ "Level",
+ "XP"
+ ],
+ "FJJIJOGNMCP": [
+ "ReferralRewardTierId",
+ "RequiredReferralCount",
+ "RewardSelectionCount"
+ ],
+ "FJNBDGFCLOF": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "FKANCKLCCDI": [
+ "Id",
+ "FromPlayerId",
+ "ConsumableItemDesc",
+ "AvatarItemType",
+ "AvatarItemDesc",
+ "EquipmentPrefabName",
+ "EquipmentModificationGuid",
+ "CurrencyType",
+ "Currency",
+ "Xp",
+ "GiftContext",
+ "GiftRarity",
+ "Message",
+ "Platform",
+ "PlatformsToSpawnOn",
+ "BalanceType"
+ ],
+ "FKPPDBLFKHJ": [
+ "Msg",
+ "Id"
+ ],
+ "FLEDNKHKIND": [
+ "InfluencerIds",
+ "ContinuationToken"
+ ],
+ "FLELPHJDLNG": [
+ "Filename",
+ "Hash",
+ "OwnershipProof"
+ ],
+ "FLMCOHEGJEI": [
+ "RequiresEliteUpgrade",
+ "GiftDrop",
+ "SeasonXpBoost"
+ ],
+ "FMIDLABJCLM": [
+ "Strings",
+ "StringPackageVersion"
+ ],
+ "FMKALNIKMKF": [
+ "AccountId",
+ "MembershipType"
+ ],
+ "FMNDPHMOIIH": [
+ "Msg",
+ "Id"
+ ],
+ "FNDBGOGAIFL": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "FNIDAIDJBBO": [
+ "PlayerID",
+ "RelationshipType",
+ "Muted",
+ "Ignored",
+ "Favorited"
+ ],
+ "FNNAJDFFICN": [
+ "ReportId",
+ "Client"
+ ],
+ "FOIMFMEEGDI": [
+ "SupportsJoinInProgress",
+ "UseLevelBasedMatchmaking",
+ "UseAgeBasedMatchmaking",
+ "UseRecRoyaleMatchmaking",
+ "SubRoomId",
+ "RoomId",
+ "UnitySceneId",
+ "Name",
+ "UnityAssetId",
+ "UnityAsset",
+ "UnityAssetHash",
+ "DataBlob",
+ "DataBlobHash",
+ "DataSavedAt",
+ "IsSandbox",
+ "MaxPlayers",
+ "Accessibility"
+ ],
+ "FOKKDBJEFOO": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "FOOOKJCPBKC": [
+ "Msg",
+ "Id"
+ ],
+ "FPGBBCLENDA": [
+ "StorefrontType",
+ "NextUpdate",
+ "NewUntil"
+ ],
+ "FPLGJGDJIJG": [
+ "StorefrontType",
+ "PurchasableItemId",
+ "CurrencyType",
+ "RequestedPrice",
+ "CouponConsumablePlayerMappingId",
+ "Gift"
+ ],
+ "FPNMHECOPPF": [
+ "UpdateResponse",
+ "Data"
+ ],
+ "FPNOOOFJBJF": [
+ "RankStart",
+ "RankEnd",
+ "PlayerId",
+ "StatChannel",
+ "RoomId",
+ "FilterType",
+ "SortAscending"
+ ],
+ "FastDualShock4GamepadHID": [
+ "Dpad",
+ "Square",
+ "Cross",
+ "Circle",
+ "Triangle"
+ ],
+ "FastKeyboard": [
+ "AnyKey",
+ "Escape",
+ "Space",
+ "Enter",
+ "Tab",
+ "A",
+ "B",
+ "C",
+ "D",
+ "E",
+ "F",
+ "G",
+ "H",
+ "I",
+ "J",
+ "K",
+ "L",
+ "M",
+ "N",
+ "O",
+ "P",
+ "Q",
+ "R",
+ "S",
+ "T",
+ "U",
+ "V",
+ "W",
+ "X",
+ "Y",
+ "Z",
+ "Shift",
+ "Alt",
+ "Backspace",
+ "Home",
+ "End",
+ "Insert",
+ "Delete",
+ "F1",
+ "F2",
+ "F3",
+ "F4",
+ "F5",
+ "F6",
+ "F7",
+ "F8",
+ "F9",
+ "F10",
+ "F11",
+ "F12"
+ ],
+ "FastMouse": [
+ "Pressure",
+ "Radius",
+ "Position",
+ "Delta",
+ "Scroll",
+ "Press"
+ ],
+ "FastTouchscreen": [
+ "Pressure",
+ "Radius",
+ "Position",
+ "Delta",
+ "Press"
+ ],
+ "GACOLGJKHBI": [
+ "VersionStatus",
+ "UpdateNotificationStage"
+ ],
+ "GAGIJHJGFJD": [
+ "AccountId",
+ "CurrencyId",
+ "Balance",
+ "AmountAwarded",
+ "AwardedAt"
+ ],
+ "GAODOHODBDE": [
+ "FailedInvites",
+ "Result"
+ ],
+ "GBPDOLJBABB": [
+ "Success",
+ "Message"
+ ],
+ "GBPHFPMCGHH": [
+ "Subscription",
+ "PlatformAccountSubscribedPlayerId"
+ ],
+ "GCKGIDLDBNH": [
+ "Error",
+ "Error_description",
+ "Access_token",
+ "Refresh_token",
+ "Key"
+ ],
+ "GDGCOAMMCIH": [
+ "MicSpamVolumeThreshold",
+ "MicVolumeSampleInterval",
+ "MicVolumeSampleRollingWindowLength",
+ "MicSpamSamplePercentageForWarning",
+ "MicSpamSamplePercentageForWarningToEnd",
+ "MicSpamSamplePercentageForForceMute",
+ "MicSpamSamplePercentageForForceMuteToEnd",
+ "MicSpamWarningStateVolumeMultiplier"
+ ],
+ "GEEKOKDMAHG": [
+ "AccountId",
+ "ApiKey"
+ ],
+ "GEGADPCDGBO": [
+ "Tag",
+ "Type"
+ ],
+ "GEMBOCLPFGE": [
+ "AccountId",
+ "FavoritedAt"
+ ],
+ "GFCIGEIEPGH": [
+ "Msg",
+ "Id"
+ ],
+ "GFGBAAADKEL": [
+ "Msg",
+ "Id"
+ ],
+ "GFGKDCENJMB": [
+ "AccountId",
+ "Xp",
+ "GameMinutesToday",
+ "RewardsCollected",
+ "BonusRewardsCollected",
+ "XpBoostLastPurchasedAt"
+ ],
+ "GFHNJMMBHFD": [
+ "PlayerEventId",
+ "Type"
+ ],
+ "GFIHPOOFHLC": [
+ "AnnouncementId",
+ "CreatorAccountId",
+ "ClubId",
+ "Title",
+ "Body",
+ "ImageName",
+ "CreatedAt",
+ "Meta"
+ ],
+ "GFKKOADFJKC": [
+ "CurrencyId",
+ "RecipientId",
+ "Amount",
+ "TransactionId"
+ ],
+ "GFMOBMCIOJI": [
+ "AccountId",
+ "IsCheerful",
+ "SelectedCheer",
+ "CheerCredit",
+ "CheerGeneral",
+ "CheerHelpful",
+ "CheerCreative",
+ "CheerGreatHost",
+ "CheerSportsman"
+ ],
+ "GGFHKCDELJH": [
+ "Id",
+ "Link"
+ ],
+ "GHBIGGJKPFI": [
+ "Events",
+ "ContinuationToken"
+ ],
+ "GIFNHCGDNEM": [
+ "Platform",
+ "PlatformId",
+ "AccountId",
+ "LastLoginTime",
+ "RequirePassword",
+ "RefreshToken"
+ ],
+ "GIJCKEDKGLH": [
+ "ReportCategory",
+ "PlayerEventId",
+ "Details"
+ ],
+ "GJGLBNABMNL": [
+ "CreatorAccountId",
+ "ClubId",
+ "MembershipType"
+ ],
+ "GKFBDCHILOD": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "GLEGPFFPDBE": [
+ "DataBlob",
+ "DataBlobHash",
+ "SubRooms",
+ "Roles",
+ "Tags",
+ "LoadScreens",
+ "PromoImages",
+ "PromoExternalContent",
+ "RoomId",
+ "IsDorm",
+ "MaxPlayerCalculationMode",
+ "MaxPlayers",
+ "PersistenceVersion",
+ "CloningAllowed",
+ "DisableMicAutoMute",
+ "DisableRoomComments",
+ "EncryptVoiceChat",
+ "ToxmodEnabled",
+ "LoadScreenLocked",
+ "IsDeveloperOwned",
+ "Name",
+ "Description",
+ "ImageName",
+ "WarningMask",
+ "CustomWarning",
+ "CreatorAccountId",
+ "State",
+ "Accessibility",
+ "SupportsLevelVoting",
+ "IsRRO",
+ "SupportsScreens",
+ "SupportsWalkVR",
+ "SupportsTeleportVR",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile",
+ "SupportsJuniors",
+ "MinLevel",
+ "CreatedAt",
+ "Stats",
+ "RankingContext"
+ ],
+ "GLENKGBDMAO": [
+ "Result",
+ "Code",
+ "Url",
+ "RedemptionUrl"
+ ],
+ "GMBGBPNMGBA": [
+ "Status",
+ "Invention",
+ "InventionVersion"
+ ],
+ "GMDIDJMOEBB": [
+ "Objectives",
+ "ObjectiveGroups"
+ ],
+ "GMFJIAMOIEO": [
+ "SubscriptionId",
+ "RecNetPlayerId",
+ "PlatformType",
+ "PlatformId",
+ "PlatformPurchaseId",
+ "Level",
+ "Period",
+ "ExpirationDate",
+ "IsAutoRenewing",
+ "CreatedAt",
+ "ModifiedAt"
+ ],
+ "GMNMHECGEGD": [
+ "CurrencyType",
+ "BalanceAdds"
+ ],
+ "GNIPAKCCOOE": [
+ "ProgressionEventId",
+ "Name",
+ "Rewards",
+ "KeepsakeRoomLists",
+ "StartTime",
+ "EndTime",
+ "CollectionEndTime",
+ "UsesBoost",
+ "BoostDailyGameplayMinutesLimit",
+ "BoostXpMultiplier",
+ "PurchasableXpBoostId",
+ "ActiveExperiment",
+ "ChallengesIconImageName",
+ "RewardsPipImageName",
+ "EventInfoImageName"
+ ],
+ "GNMDEJJAHCJ": [
+ "SubscriberDiscountPercent",
+ "StorefrontType",
+ "NextUpdate",
+ "NewUntil",
+ "StoreItems"
+ ],
+ "GOHGJADGDJF": [
+ "ReplicationId",
+ "DataBlob",
+ "SubRooms",
+ "Tags",
+ "UnityAssets",
+ "RoomId",
+ "IsDorm",
+ "MaxPlayerCalculationMode",
+ "MaxPlayers",
+ "PersistenceVersion",
+ "CloningAllowed",
+ "DisableMicAutoMute",
+ "DisableRoomComments",
+ "EncryptVoiceChat",
+ "ToxmodEnabled",
+ "LoadScreenLocked",
+ "IsDeveloperOwned",
+ "Name",
+ "Description",
+ "ImageName",
+ "WarningMask",
+ "CustomWarning",
+ "CreatorAccountId",
+ "State",
+ "Accessibility",
+ "SupportsLevelVoting",
+ "IsRRO",
+ "SupportsScreens",
+ "SupportsWalkVR",
+ "SupportsTeleportVR",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile",
+ "SupportsJuniors",
+ "MinLevel",
+ "CreatedAt",
+ "Stats",
+ "RankingContext"
+ ],
+ "GUISkin": [
+ "HorizontalSliderThumbExtent",
+ "VerticalSliderThumbExtent"
+ ],
+ "HADOHMCCLEO": [
+ "Msg",
+ "Id"
+ ],
+ "HBKAFCAEFCC": [
+ "CreatedAt",
+ "Comment"
+ ],
+ "HDLOMAGLILM": [
+ "Msg",
+ "Id"
+ ],
+ "HDOJMEBCAGP": [
+ "Permission",
+ "Role",
+ "Override",
+ "Type",
+ "Value"
+ ],
+ "HECILPKCKMK": [
+ "FeaturedRoomGroupId",
+ "Name",
+ "Rooms"
+ ],
+ "HEIPMIFEFEE": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "HEKMOMJFFGJ": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "HFHNOHAODOF": [
+ "ValidTill",
+ "Images"
+ ],
+ "HFODJDLIJPG": [
+ "TargetPlayerId",
+ "RoomName",
+ "ActionCode"
+ ],
+ "HGJDGKFIOCK": [
+ "Msg",
+ "Id"
+ ],
+ "HHJLCKPJOIA": [
+ "PlayerId",
+ "StatusVisibility",
+ "DeviceClass",
+ "VrMovementMode",
+ "RoomInstance",
+ "IsOnline",
+ "AppVersion",
+ "ClientJoinData"
+ ],
+ "HHOJLJGGNKN": [
+ "Msg",
+ "Id"
+ ],
+ "HIKOJENMDKE": [
+ "KeepsakeCategoryId",
+ "VisualId",
+ "LimitPerRoom",
+ "XpValue",
+ "IconOutlineImageName",
+ "IconFilledImageName"
+ ],
+ "HIOJNFKGAOK": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "HIOPBEPNFOO": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "HJKLIIPPPEM": [
+ "PlayerEvent",
+ "PlayerEventResponse"
+ ],
+ "HJOIJBMAIPO": [
+ "PlaylistId",
+ "Name",
+ "Description",
+ "ImageName",
+ "WarningMask",
+ "CustomWarning",
+ "CreatorAccountId",
+ "State",
+ "Accessibility",
+ "SupportsLevelVoting",
+ "IsRRO",
+ "SupportsScreens",
+ "SupportsWalkVR",
+ "SupportsTeleportVR",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile",
+ "SupportsJuniors",
+ "MinLevel",
+ "CreatedAt",
+ "Stats",
+ "RankingContext"
+ ],
+ "HJOKFJKKEJM": [
+ "BalanceUpdateResult",
+ "TokenBalanceResponse",
+ "OperationResult"
+ ],
+ "HJPEABLEEKL": [
+ "RoomConsumableId",
+ "RoomId",
+ "Name",
+ "Description",
+ "ImageName",
+ "PriceAndCurrency"
+ ],
+ "HKAGNIGNBOJ": [
+ "Msg",
+ "Id"
+ ],
+ "HKIJINJGBGD": [
+ "Type",
+ "User_id",
+ "Identifiers",
+ "Ip",
+ "Gameplay_session_id",
+ "Transaction_id"
+ ],
+ "HKPKGOPHLHE": [
+ "RoomInstanceCount",
+ "PlayerCount"
+ ],
+ "HLDLKOPNDPF": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "HMDONHHKKGA": [
+ "PinnedFilters",
+ "PopularFilters",
+ "TrendingFilters"
+ ],
+ "HMGHGBMGNJG": [
+ "Msg",
+ "Id"
+ ],
+ "HMKMGPNMHBM": [
+ "Instances",
+ "CollectionRecords",
+ "KeepsakeProgressionEventIds"
+ ],
+ "HMMFNEEAFGP": [
+ "Results",
+ "TotalResults"
+ ],
+ "HNLGHDMNMDB": [
+ "UpdateResponse",
+ "Data"
+ ],
+ "HOGIMIPDDGP": [
+ "ImageName",
+ "ImageUrl"
+ ],
+ "HPAKKJFOFDF": [
+ "Msg",
+ "Id"
+ ],
+ "HPEGLGGMNMC": [
+ "ChatThread",
+ "ChatResult"
+ ],
+ "HPMMKFGDAEC": [
+ "PlayerEventId",
+ "CreatorPlayerId",
+ "RoomId",
+ "SubRoomId",
+ "ClubId",
+ "Name",
+ "Description",
+ "ImageName",
+ "StartTime",
+ "EndTime",
+ "AttendeeCount",
+ "Accessibility",
+ "IsMultiInstance",
+ "SupportMultiInstanceRoomChat",
+ "DefaultBroadcastPermissions",
+ "CanRequestBroadcastPermissions",
+ "BroadcastingRoomInstanceId"
+ ],
+ "IACBFJEMJMJ": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "IAPMDDFENDD": [
+ "PurchasableItemId",
+ "CouponConsumablePlayerMappingId",
+ "Count",
+ "CreatorPlayerId",
+ "RequestedPrice"
+ ],
+ "IBAPHBCOBHL": [
+ "BalanceUpdates",
+ "Balance",
+ "CurrencyType",
+ "Platform"
+ ],
+ "IBILPLGNAJE": [
+ "Id",
+ "ImageName",
+ "PlayerId",
+ "RoomId",
+ "PlayerEventId",
+ "Accessibility",
+ "AccessibilityLocked",
+ "Type",
+ "CreatedAt",
+ "TaggedPlayerIds",
+ "CheerCount",
+ "CommentCount"
+ ],
+ "ICMBGNCJMJG": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "ICNLODNGFJJ": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "IDBFKCFEAHM": [
+ "BalanceUpdateResult",
+ "CurrencyBalanceResponse",
+ "OperationResult"
+ ],
+ "IDBLMMAGACO": [
+ "Key",
+ "Value"
+ ],
+ "IDFEIMCEMCC": [
+ "Level",
+ "Period",
+ "IsAutoRenewing"
+ ],
+ "IEIKHFIPDMD": [
+ "CurrencyType",
+ "Price",
+ "StorefrontSaleData"
+ ],
+ "IFFEHJEPKOH": [
+ "ReferralsVerifiedCount",
+ "PlayerReferralRewards"
+ ],
+ "IFLDEEGLKFG": [
+ "Level",
+ "RequiredXp",
+ "GiftDropId"
+ ],
+ "IGMCJEFBJCF": [
+ "ServerTime",
+ "PlayerId",
+ "StatusVisibility",
+ "DeviceClass",
+ "VrMovementMode",
+ "RoomInstance",
+ "IsOnline",
+ "AppVersion",
+ "ClientJoinData"
+ ],
+ "IGNECOJDECH": [
+ "Info",
+ "ReferrerId"
+ ],
+ "IHGDKHPLIGH": [
+ "RoomId",
+ "RoomName",
+ "ImageName"
+ ],
+ "IJFKOMJBJMI": [
+ "StatChannel",
+ "RoomId",
+ "StatValue",
+ "CurrentStatValue"
+ ],
+ "ILAHMLLJDLE": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "ILEHDPADJJF": [
+ "Nouns",
+ "Adjectives"
+ ],
+ "ILJOBJDPBNJ": [
+ "Name",
+ "Description",
+ "Price",
+ "BaseAvatarItemId",
+ "BaseAvatarItemColor",
+ "Accessibility"
+ ],
+ "IMMOJHBENFI": [
+ "Slot",
+ "PreviewImageName",
+ "OutfitSelections",
+ "OutfitSelectionsV2",
+ "FaceFeatures",
+ "SkinColor",
+ "HairColor",
+ "CustomAvatarItems"
+ ],
+ "INLPIKBODME": [
+ "CreatorAccountId",
+ "BaseAvatarItemId",
+ "DesignFilename",
+ "BaseAvatarItemColor"
+ ],
+ "IOEPPCKGBFL": [
+ "InventionId",
+ "ReplicationId",
+ "CreatorPlayerId",
+ "Name",
+ "Description",
+ "ImageName",
+ "CurrentVersionNumber",
+ "Accessibility",
+ "ModifiedAt",
+ "CreatedAt",
+ "FirstPublishedAt",
+ "CreationRoomId",
+ "NumPlayersHaveUsedInRoom",
+ "NumDownloads",
+ "CheerCount",
+ "CreatorPermission",
+ "GeneralPermission",
+ "IsAGInvention",
+ "IsCertifiedInvention",
+ "AllowTrial",
+ "Price",
+ "HideFromPlayer"
+ ],
+ "IPMGNJMIONC": [
+ "Revenue_currency",
+ "Revenue_amount",
+ "Revenue_type",
+ "Type",
+ "User_id",
+ "Identifiers",
+ "Ip",
+ "Gameplay_session_id",
+ "Transaction_id"
+ ],
+ "JAFDEPHGOAF": [
+ "Title",
+ "Message",
+ "ImageName",
+ "ActiveExperiments",
+ "Context",
+ "InputType",
+ "Visibility",
+ "AllowCycling",
+ "PlatformMask",
+ "RestrictToNewUsers",
+ "RoomNames"
+ ],
+ "JAHJGFHFKIB": [
+ "Club",
+ "CustomTags",
+ "AdditionalImages",
+ "CoownerPermissions",
+ "ModeratorPermissions",
+ "MemberPermissions",
+ "MyMembershipType"
+ ],
+ "JBGKGCLPCHP": [
+ "Results",
+ "TotalResults"
+ ],
+ "JBIEJIKHBMG": [
+ "SeedRoom",
+ "Rooms"
+ ],
+ "JCPFCOEEELK": [
+ "AccountId",
+ "CurrencyId",
+ "Success",
+ "Error",
+ "Response"
+ ],
+ "JDEKAFDPGBE": [
+ "ReplicationId",
+ "PersistenceVersion",
+ "SupportsJoinInProgress",
+ "UseLevelBasedMatchmaking",
+ "UseAgeBasedMatchmaking",
+ "UseRecRoyaleMatchmaking",
+ "SubRoomId",
+ "RoomId",
+ "UnitySceneId",
+ "Name",
+ "UnityAssetId",
+ "UnityAsset",
+ "UnityAssetHash",
+ "DataBlob",
+ "DataBlobHash",
+ "DataSavedAt",
+ "IsSandbox",
+ "MaxPlayers",
+ "Accessibility"
+ ],
+ "JEBCHNDADKK": [
+ "ConcurrencyCodes",
+ "ExpectedPriceAndCurrency"
+ ],
+ "JFEKPNOKFBP": [
+ "Msg",
+ "Id"
+ ],
+ "JGJFCFAFGCM": [
+ "TotalXP",
+ "Level",
+ "RankIdx",
+ "RankName",
+ "CurrentLevelXPThreshold",
+ "NextLevelXPThreshold",
+ "NextLevelAcornReward"
+ ],
+ "JHNAJMDHFKH": [
+ "Msg",
+ "Id"
+ ],
+ "JICIOMDBPDO": [
+ "Id",
+ "ConsumableItemDesc",
+ "Count",
+ "InitialCount",
+ "CreatedAt",
+ "ActiveDurationMinutes",
+ "IsActive",
+ "IsTransferable"
+ ],
+ "JIOEIEENGNO": [
+ "Msg",
+ "Id"
+ ],
+ "JJEKBGHDCEG": [
+ "Tier",
+ "Rewards",
+ "PurchasableItemId",
+ "Type",
+ "Prices",
+ "SubscriberPrices",
+ "IsFeatured",
+ "AvailableAt",
+ "AvailableUntil",
+ "NewUntil"
+ ],
+ "JJGEJHCFEJH": [
+ "GiftDropId",
+ "ConsumableItemDesc",
+ "AvatarItemDesc",
+ "AvatarItemType",
+ "EquipmentPrefabName",
+ "EquipmentModificationGuid",
+ "StorefrontType",
+ "Xp",
+ "Level",
+ "GiftContext",
+ "GiftRarity"
+ ],
+ "JKJHOAKNOCF": [
+ "CurrentConcurrencyCode",
+ "NewConcurrencyCode"
+ ],
+ "JLIBBFMMLFF": [
+ "CurrencyType",
+ "BalanceAddType",
+ "BaseAward",
+ "BonusAwardMin",
+ "BonusAwardMax",
+ "RateLimitType",
+ "IgnorePartialMultiplier",
+ "MaxPartialMultiplier",
+ "RateLimit",
+ "BalanceInGiftBox"
+ ],
+ "JLNFEFHNFBP": [
+ "Title",
+ "Description"
+ ],
+ "JLNKMODEKKM": [
+ "BaseAvatarItemId",
+ "BaseAvatarItemColor"
+ ],
+ "JMJLMOJCBCK": [
+ "UpdateResponse",
+ "Data"
+ ],
+ "JNFDPPCIPHG": [
+ "CategoryId",
+ "Importance",
+ "Name",
+ "Description",
+ "IsMuteable"
+ ],
+ "JOMMALIPDGI": [
+ "RoomId",
+ "IsDorm",
+ "MaxPlayerCalculationMode",
+ "MaxPlayers",
+ "PersistenceVersion",
+ "CloningAllowed",
+ "DisableMicAutoMute",
+ "DisableRoomComments",
+ "EncryptVoiceChat",
+ "ToxmodEnabled",
+ "LoadScreenLocked",
+ "IsDeveloperOwned",
+ "Name",
+ "Description",
+ "ImageName",
+ "WarningMask",
+ "CustomWarning",
+ "CreatorAccountId",
+ "State",
+ "Accessibility",
+ "SupportsLevelVoting",
+ "IsRRO",
+ "SupportsScreens",
+ "SupportsWalkVR",
+ "SupportsTeleportVR",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile",
+ "SupportsJuniors",
+ "MinLevel",
+ "CreatedAt",
+ "Stats",
+ "RankingContext"
+ ],
+ "JPKBLKGJNCA": [
+ "Msg",
+ "Id"
+ ],
+ "JPLIIMKJAEL": [
+ "AccountId",
+ "DisallowInAppPurchases"
+ ],
+ "JPNMBICAMEE": [
+ "RoomInstanceId",
+ "RoomId",
+ "SubRoomId",
+ "IsFull",
+ "CreatedAt",
+ "PlayerIds"
+ ],
+ "JsonParser_NestedType_JsonValue": [
+ "False",
+ "True"
+ ],
+ "KAONMCEONPF": [
+ "Name",
+ "Price",
+ "ProductId"
+ ],
+ "KBHFIPFPCMN": [
+ "Msg",
+ "Id"
+ ],
+ "KBLOCDGCGFA": [
+ "Msg",
+ "Id"
+ ],
+ "KBMAAKAIPLE": [
+ "UpdateResponse",
+ "Data"
+ ],
+ "KCMDEFMLDJF": [
+ "Balance",
+ "CurrencyType",
+ "BalanceType",
+ "Data"
+ ],
+ "KDAHIOHGPBN": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "KDJILJAIDFG": [
+ "RoomId",
+ "Count"
+ ],
+ "KEOLHAONMIF": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "KFAOOJLFCGC": [
+ "Msg",
+ "Id"
+ ],
+ "KFPHOKPEMOF": [
+ "ItemType",
+ "ItemId",
+ "Name",
+ "Description",
+ "ImageName",
+ "RoomId",
+ "Price",
+ "PurchaseCurrencyId",
+ "CreatedAt",
+ "ModifiedAt"
+ ],
+ "KGNFNBHMAFL": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "KHBGNAMFAHK": [
+ "Msg",
+ "Id"
+ ],
+ "KHFKIJEGPDG": [
+ "AccountId",
+ "RoomKeyId",
+ "DoesPlayerOwnRoomKey"
+ ],
+ "KIANAKFJGCP": [
+ "FeaturedPlayer",
+ "FeaturedRoomGroup",
+ "CurrentAnnouncement",
+ "InstagramImages",
+ "Videos"
+ ],
+ "KILGEFOENFN": [
+ "BalanceUpdates",
+ "Balance",
+ "CurrencyType",
+ "Platform"
+ ],
+ "KJEIGGBIIHP": [
+ "SavedImageId",
+ "IsCheered"
+ ],
+ "KKOHONLGIHL": [
+ "Id",
+ "FromPlayerId",
+ "SentTime",
+ "Type",
+ "Data",
+ "RoomId",
+ "PlayerEventId"
+ ],
+ "KLHCKBPJKPF_NestedType_DHKPMMHMIJF_NestedType_KKNKAJBPNPC": [
+ "SerializeUnsafe",
+ "ToJsonString"
+ ],
+ "KMBHAKAHNGH": [
+ "Room",
+ "SubRoomDataSave"
+ ],
+ "KMHNOGNHDLO": [
+ "SubscriptionSeasonMilestoneId",
+ "SeasonId",
+ "StartAt",
+ "GiftDrops"
+ ],
+ "KNGMIABFJHK": [
+ "CurrencyBalanceResponse",
+ "TokenBalanceResponse"
+ ],
+ "KOEEACLMAFE": [
+ "Referrals",
+ "ContinuationToken"
+ ],
+ "KPEIHOCIGGE": [
+ "Msg",
+ "Id"
+ ],
+ "KPMMGJELBPP": [
+ "WindowSize",
+ "PlayerId",
+ "StatChannel",
+ "RoomId",
+ "FilterType",
+ "SortAscending"
+ ],
+ "KPNBKKKCOCA": [
+ "UnlockedAt",
+ "ClaimedAt",
+ "Status",
+ "ReferralRewardTier",
+ "IsCurrentTier"
+ ],
+ "KPPINBHEKHE": [
+ "Products",
+ "Identifier",
+ "Genuine",
+ "GenuineCheckAvailable"
+ ],
+ "LAIJIAIHGCO": [
+ "ReportCategory",
+ "Duration",
+ "GameSessionId",
+ "IsHostKick",
+ "Message",
+ "PlayerIdReporter",
+ "IsBan",
+ "IsVoiceModAutoban",
+ "IsWarning",
+ "VoteKickReason",
+ "TimeoutStartedAt"
+ ],
+ "LBOGFNBPIKJ": [
+ "SubRoomId",
+ "RoomId",
+ "UnitySceneId",
+ "Name",
+ "UnityAssetId",
+ "UnityAsset",
+ "UnityAssetHash",
+ "DataBlob",
+ "DataBlobHash",
+ "DataSavedAt",
+ "IsSandbox",
+ "MaxPlayers",
+ "Accessibility"
+ ],
+ "LDGHHCCJAMC": [
+ "Status",
+ "RoomKey"
+ ],
+ "LEEAJGDIOHI": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "LEINHCHGFON": [
+ "Msg",
+ "Id"
+ ],
+ "LEMIBENHABD": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "LIJHBNDJKNP": [
+ "RewardSelectionId",
+ "RewardType",
+ "Message",
+ "GiftContext",
+ "GiftDrop1",
+ "GiftDrop2",
+ "GiftDrop3",
+ "Subscriber_GiftDrop3",
+ "CreatedAt"
+ ],
+ "LIODMDILCAJ": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "LKBGCOELLIN": [
+ "CurrencyId",
+ "PurchaseOffers"
+ ],
+ "LKJLPNENINM": [
+ "RoomKeyResponse",
+ "BalanceUpdateResponse"
+ ],
+ "LKJPKMBEPCG": [
+ "PrefabName",
+ "ModificationGuid",
+ "FriendlyName",
+ "Tooltip",
+ "Rarity",
+ "Favorited"
+ ],
+ "LKLKHEEHEBC": [
+ "UnityAssetId",
+ "Target",
+ "Version",
+ "Filename",
+ "Hash"
+ ],
+ "LKMEPPOFHLK": [
+ "Msg",
+ "Id"
+ ],
+ "LMCCNLLHLCJ": [
+ "Success",
+ "Error"
+ ],
+ "LMEECKCCPHG": [
+ "AccountId",
+ "Role",
+ "LastChangedByAccountId",
+ "InvitedRole"
+ ],
+ "LMHKHIJIDEO": [
+ "SavedImageId",
+ "Cheer"
+ ],
+ "LNMAMOCCBAK": [
+ "Filename",
+ "UnityAssetId",
+ "CreatedByAccountId",
+ "BakedUnityAssets"
+ ],
+ "LOEHGGMCKAM": [
+ "ChatResult",
+ "SnoozedUntil"
+ ],
+ "LOOLOADLJPG": [
+ "SubscriptionSeasonId",
+ "Name",
+ "ImageName",
+ "StartAt",
+ "NextSeasonStart",
+ "Milestones"
+ ],
+ "LPAEOGFPGBD": [
+ "UnitySceneId",
+ "Name",
+ "MaxPlayers",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile"
+ ],
+ "LPFAFIGAIOE": [
+ "Status",
+ "InventoryItem"
+ ],
+ "MACBLLENLFD": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "MADLMAFINGF": [
+ "KeepsakeInstanceId",
+ "KeepsakeCategoryConfigId",
+ "PlacedByAccountId",
+ "RoomId",
+ "SubRoomId"
+ ],
+ "MAHHAAEGEEP": [
+ "InventionResponse",
+ "BalanceUpdateResponse"
+ ],
+ "MBFADJPOGDI": [
+ "Msg",
+ "Id"
+ ],
+ "MCDADMNCDEI": [
+ "WishlistItemId",
+ "AccountId",
+ "PurchasableItemId",
+ "CreatedAt"
+ ],
+ "MCMPJPNBJBI": [
+ "Msg",
+ "Id"
+ ],
+ "MEBAFOGDHBB": [
+ "PurchaseCampaignId",
+ "PurchaseReminder",
+ "Name",
+ "TriggerFlags",
+ "LastShown",
+ "ShowCount",
+ "DaysBetweenPopups",
+ "MaxPopups"
+ ],
+ "MEEAEDNAPGI": [
+ "CommentId",
+ "RoomId",
+ "SubRoomId",
+ "AccountId",
+ "CreatedAt",
+ "Message",
+ "Style",
+ "Unread",
+ "PositionX",
+ "PositionY",
+ "PositionZ"
+ ],
+ "MFNBIPCHKHC": [
+ "MembershipRequests",
+ "TotalMembershipRequests",
+ "ContinuationToken"
+ ],
+ "MGOLBEMMNDA": [
+ "BalanceAddType",
+ "BaseAward",
+ "BonusAward",
+ "RateLimit",
+ "CurrentCount",
+ "Total",
+ "Platform",
+ "BalanceInGiftBox"
+ ],
+ "MIFGPMGEMEK": [
+ "RefreshesAt",
+ "RefreshIntervalMinutes",
+ "Room"
+ ],
+ "MIHDILLIAFE": [
+ "Instances",
+ "CollectionRecords"
+ ],
+ "MIHJPKMLADF": [
+ "Msg",
+ "Id"
+ ],
+ "MIHKAJFMIPE": [
+ "RoomId",
+ "CurrencyId"
+ ],
+ "MJGHCJDOJLH": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "MJJDCIAIDKI": [
+ "HasEliteUpgrade",
+ "HasEliteUpgradePlatformMask",
+ "CurrentSeasonTier",
+ "ModifiedAt"
+ ],
+ "MJKDDBKGNOP": [
+ "ContactDetail",
+ "AccountId",
+ "UserName",
+ "DisplayName",
+ "DisplayEmoji",
+ "ProfileImage",
+ "BannerImage",
+ "TreatAsJunior",
+ "HasBirthday",
+ "PersonalPronouns",
+ "IdentityFlags",
+ "CreatedAt",
+ "IsJunior"
+ ],
+ "MJNABIBJDMO": [
+ "Order",
+ "Objective",
+ "Count"
+ ],
+ "MKAIABAJIAJ": [
+ "CustomAvatarItemId",
+ "BodyPart"
+ ],
+ "MKBLHNGJOBA": [
+ "Msg",
+ "Id"
+ ],
+ "MLHBPDKBABN": [
+ "CheerCount",
+ "FavoriteCount",
+ "VisitorCount",
+ "VisitCount"
+ ],
+ "MMDMDIJLIEH": [
+ "Msg",
+ "Id"
+ ],
+ "MMFCMCPCAJP": [
+ "ChatMessage",
+ "ChatResult"
+ ],
+ "MMPLMIAEBLE": [
+ "Msg",
+ "Id"
+ ],
+ "MOJLGDKFECO": [
+ "InvitedPlayerId",
+ "Result"
+ ],
+ "MOLNFPGNEGP": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "MOMDMDMGEOA": [
+ "ItemType",
+ "ItemId"
+ ],
+ "Matchmaking": [
+ "PlayerId",
+ "RoomInstanceId"
+ ],
+ "NAGNGCMPGOP": [
+ "RoomId",
+ "KeepsakeInstanceIds"
+ ],
+ "NAPOKFHELLC": [
+ "Multiplier",
+ "BalanceAddType"
+ ],
+ "NBGPICODFEE": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "NCBBKAMCPBC": [
+ "Msg",
+ "Id"
+ ],
+ "NCHBANKEJMP": [
+ "Results",
+ "TotalResults"
+ ],
+ "NDIIFIGNAAM": [
+ "Error",
+ "Identifier",
+ "Genuine",
+ "GenuineCheckAvailable"
+ ],
+ "NEJOPOKDPKE": [
+ "Msg",
+ "Id"
+ ],
+ "NEKEOJFNCCJ": [
+ "UpdateResponse",
+ "Data"
+ ],
+ "NFBJOAGJHME": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "NGCPIKDINEI": [
+ "Provider",
+ "Data"
+ ],
+ "NHGOFGABJIN": [
+ "RoomInstanceId",
+ "RoomId",
+ "SubRoomId",
+ "Location",
+ "EventId",
+ "ClubId",
+ "RoomCode",
+ "PhotonRegionId",
+ "PhotonRoomId",
+ "Name",
+ "MaxCapacity",
+ "IsFull",
+ "IsPrivate",
+ "IsInProgress",
+ "EncryptVoiceChat",
+ "RoomInstanceType"
+ ],
+ "NIJPNDPJICF": [
+ "Id",
+ "Key",
+ "Title",
+ "Description",
+ "RoomName",
+ "Status",
+ "AssignedPlayerNames",
+ "Tags",
+ "JiraUrl",
+ "Comments"
+ ],
+ "NKMEHOHCKOH": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "NLIAAGLJBNN": [
+ "Results",
+ "TotalResults"
+ ],
+ "NMGHOIMEJIK": [
+ "Name",
+ "Description",
+ "Price",
+ "Accessibility"
+ ],
+ "NNAGOPNGJFE": [
+ "AmplitudeKey",
+ "RudderStackKey",
+ "UseRudderStack",
+ "StatSigKey"
+ ],
+ "OANOOFEPJCI": [
+ "Rooms",
+ "Tags",
+ "PlaylistId",
+ "Name",
+ "Description",
+ "ImageName",
+ "WarningMask",
+ "CustomWarning",
+ "CreatorAccountId",
+ "State",
+ "Accessibility",
+ "SupportsLevelVoting",
+ "IsRRO",
+ "SupportsScreens",
+ "SupportsWalkVR",
+ "SupportsTeleportVR",
+ "SupportsVRLow",
+ "SupportsQuest2",
+ "SupportsMobile",
+ "SupportsJuniors",
+ "MinLevel",
+ "CreatedAt",
+ "Stats",
+ "RankingContext"
+ ],
+ "OBACBEGPKAK": [
+ "ReportCategory",
+ "Details",
+ "ReportedPlayerId"
+ ],
+ "OBGIGMCOMDE": [
+ "AdCarouselItemId",
+ "ImageName",
+ "Title",
+ "Description",
+ "PurchasableItemIds",
+ "PurchaseReminderId"
+ ],
+ "OBIBPCLDPGA": [
+ "Message",
+ "MoreInfoUrl"
+ ],
+ "OCFNOBAONAP": [
+ "Cheered",
+ "Favorited",
+ "LastVisitedAt"
+ ],
+ "OCGICPIMNEB": [
+ "ErrorCode",
+ "RoomInstance"
+ ],
+ "ODNKLIIJIOH": [
+ "Msg",
+ "Id"
+ ],
+ "OEHMNIAOAOH": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "OFGIPBHIAGF": [
+ "ProgressionEventPurchasableXpBoostId",
+ "Cost",
+ "XpMultiplier",
+ "XpCap",
+ "LookbackDurationTicks",
+ "CooldownDurationTicks"
+ ],
+ "OFMNNCMPEPA": [
+ "SessionId",
+ "RoomId",
+ "SubRoomId",
+ "UnityAssetId",
+ "RoomData",
+ "SubRoomData",
+ "SavedByAccountId"
+ ],
+ "OGCMIDEFOEE": [
+ "ChatResult",
+ "IsFavorited"
+ ],
+ "OGEDLCFDDIO": [
+ "Msg",
+ "Id"
+ ],
+ "OHNEMFHCBIJ": [
+ "Value",
+ "Success",
+ "Error"
+ ],
+ "OKHGCKDIILD": [
+ "ImageName",
+ "Index"
+ ],
+ "OLACHOEDKFB": [
+ "ReportCategory",
+ "YesVotes",
+ "NoVotes",
+ "PlayerIdReported"
+ ],
+ "OLIHBGPDPHF": [
+ "Balance",
+ "CurrencyType",
+ "BalanceType",
+ "Data"
+ ],
+ "OLKCALBDDOI": [
+ "Msg",
+ "Id"
+ ],
+ "OLPHKLCPFEF": [
+ "InventionId",
+ "ReplicationId",
+ "VersionNumber",
+ "InstantiationCost",
+ "LightsCost",
+ "ChipsCost",
+ "CloudVariablesCost",
+ "BlobName",
+ "BlobHash"
+ ],
+ "OMAPLLOIDNA": [
+ "ToPlayerIds",
+ "Message",
+ "GiftContext"
+ ],
+ "ONGJBLFBHGC": [
+ "PurchasableItemId",
+ "Type",
+ "Prices",
+ "SubscriberPrices",
+ "IsFeatured",
+ "AvailableAt",
+ "AvailableUntil",
+ "NewUntil"
+ ],
+ "OOGKPMKKJBE": [
+ "SalePercent",
+ "SaleStartDate",
+ "SaleEndDate"
+ ],
+ "PAKGKIKFGAE": [
+ "Id",
+ "TitleOverride",
+ "UrlOverride"
+ ],
+ "PCBAACNNDBH": [
+ "FriendlyImageName",
+ "ImageName",
+ "StartTime",
+ "EndTime"
+ ],
+ "PCMLHLIBLNJ": [
+ "Tag",
+ "Type"
+ ],
+ "PCODFCJECKL": [
+ "Msg",
+ "Id"
+ ],
+ "PCPOMPIAFMA": [
+ "Key",
+ "Value",
+ "StartTime",
+ "EndTime"
+ ],
+ "PDGENCNGCJI": [
+ "RoomId",
+ "Ids"
+ ],
+ "PDIFGFKAMBG": [
+ "PlayerCount",
+ "IsFull",
+ "PlayerEventId",
+ "CreatorPlayerId",
+ "RoomId",
+ "SubRoomId",
+ "ClubId",
+ "Name",
+ "Description",
+ "ImageName",
+ "StartTime",
+ "EndTime",
+ "AttendeeCount",
+ "Accessibility",
+ "IsMultiInstance",
+ "SupportMultiInstanceRoomChat",
+ "DefaultBroadcastPermissions",
+ "CanRequestBroadcastPermissions",
+ "BroadcastingRoomInstanceId"
+ ],
+ "PECGEJAAMHB": [
+ "Balance",
+ "CurrencyType",
+ "Platform"
+ ],
+ "PELEHJLMKJO": [
+ "SubRoomDataSaveId",
+ "SubRoomId",
+ "UnityAssetId",
+ "DataBlob",
+ "DataBlobHash",
+ "SavedByAccountId",
+ "SavedOnPlatform",
+ "SavedOnDeviceClass",
+ "Description",
+ "CreatedAt"
+ ],
+ "PEOAOMGOMGC": [
+ "CurrencyId",
+ "RoomId",
+ "Name",
+ "Description",
+ "CurrencyType",
+ "Limit",
+ "ImageName",
+ "CreatedAt",
+ "ModifiedAt"
+ ],
+ "PGACEMBEGJF": [
+ "ToPlayerId",
+ "Message",
+ "Anonymous",
+ "GiftContext"
+ ],
+ "PJPBJKLDMNA": [
+ "Results",
+ "TotalResults"
+ ],
+ "PKCMBJFBHBO": [
+ "TotalXp",
+ "SocialBoostXp"
+ ],
+ "PLLNLPADHCN": [
+ "Resolution",
+ "Language",
+ "Timezone",
+ "Platform"
+ ],
+ "POGBAGAHGIA": [
+ "GiftDrop",
+ "PurchasableItemId",
+ "Type",
+ "Prices",
+ "SubscriberPrices",
+ "IsFeatured",
+ "AvailableAt",
+ "AvailableUntil",
+ "NewUntil"
+ ],
+ "PPHLKNGCGOE": [
+ "SessionId",
+ "RoomId",
+ "SubRoomId",
+ "UnityAssetId",
+ "RoomDataFilename",
+ "RoomDataHash",
+ "SubRoomDataFilename",
+ "SubRoomDataHash"
+ ],
+ "PlayerInput": [
+ "OnPlayerLeft",
+ "OnDeviceLost",
+ "OnDeviceRegained",
+ "OnControlsChanged"
+ ],
+ "PlayerInputManager": [
+ "OnPlayerJoined",
+ "OnPlayerLeft"
+ ],
+ "StringHelpers": [
+ "Mouse",
+ "Mice",
+ "Axis",
+ "Axes"
+ ],
+ "X509Name": [
+ "C",
+ "O",
+ "T",
+ "OU",
+ "CN",
+ "L",
+ "ST",
+ "SERIALNUMBER",
+ "E",
+ "DC",
+ "UID",
+ "STREET",
+ "SURNAME",
+ "GIVENNAME",
+ "INITIALS",
+ "GENERATION",
+ "UniqueIdentifier",
+ "DN",
+ "Pseudonym",
+ "PostalAddress",
+ "CountryOfCitizenship",
+ "CountryOfResidence",
+ "Gender",
+ "PlaceOfBirth",
+ "DateOfBirth",
+ "PostalCode",
+ "BusinessCategory",
+ "TelephoneNumber"
+ ],
+ "XSDSchema": [
+ "True",
+ "False",
+ "Name"
+ ],
+ "XmlTreeGen": [
+ "Namespace",
+ "Unique",
+ "Attribute"
+ ]
+}
diff --git a/DorkNet.Server.Tests/data/client-routes-2023.tsv b/DorkNet.Server.Tests/data/client-routes-2023.tsv
new file mode 100644
index 0000000..2078948
--- /dev/null
+++ b/DorkNet.Server.Tests/data/client-routes-2023.tsv
@@ -0,0 +1,525 @@
+# Every HTTP request the Rec Room 2023-03-21 client can issue.
+# Recovered from the client binary; see docs/recroom-2023-client-api-complete.md.
+# verbroutesubsystem
+GET account/bulk identity-account
+POST account/bulk identity-account
+GET account/isactivecreator/me identity-account
+DELETE account/me identity-account
+GET account/me identity-account
+POST account/me/changepassword identity-account
+POST account/me/createlogintoken identity-account
+GET account/me/haspassword identity-account
+POST account/me/remoteauth identity-account
+POST account/recoverpassword identity-account
+GET account/search identity-account
+GET account/{0}/bio identity-account
+GET account/{0}/clubs identity-account
+PUT accountprivacysettings/recenthistoryvisibility identity-account
+GET accountprivacysettings/{0} identity-account
+GET accounts/{0}/receives/{1} identity-account
+POST actionlink config-telemetry
+GET actionlink/{code} config-telemetry
+POST actionlink/{code}/consume config-telemetry
+GET announcements/club/{clubId} config-telemetry
+POST announcements/club/{clubId} config-telemetry
+DELETE announcements/club/{clubId}/{announcementId} config-telemetry
+PUT announcements/club/{clubId}/{announcementId} config-telemetry
+POST announcements/club/{clubId}/{announcementId}/read config-telemetry
+GET announcements/mine config-telemetry
+GET announcements/subscription/mine config-telemetry
+GET announcements/v2/mine/unread config-telemetry
+GET announcements/v2/mine/unread?sendAnnouncements=true config-telemetry
+POST api/AppIntegrity/v1/iospaymentqueuefailed commerce-subscription
+POST api/AppIntegrity/v1/iosproducts commerce-subscription
+GET api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers commerce-subscription
+POST api/CampusCard/v1/UpdateAndGetSubscription commerce-subscription
+POST api/PlayerCheer/v1/SetSelectedCheer progression-misc
+POST api/PlayerCheer/v1/create progression-misc
+POST api/PlayerReporting/v1/deviceId moderation-safety
+POST api/PlayerReporting/v1/hile moderation-safety
+POST api/PlayerReporting/v1/instantKick moderation-safety
+GET api/PlayerReporting/v1/moderationBlockDetails moderation-safety
+GET api/PlayerReporting/v1/voteToKickReasons moderation-safety
+POST api/PlayerReporting/v3/create moderation-safety
+POST api/PlayerReporting/v3/voteToKick moderation-safety
+GET api/activities/charades/v1/words/{0} progression-misc
+POST api/ageverification/generateCode moderation-safety
+GET api/announcement/v1/get config-telemetry
+GET api/apple/musicpromotion/active commerce-subscription
+GET api/apple/musicpromotion/code commerce-subscription
+GET api/avatar/v1/defaultbaseavataritems avatar-inventory
+GET api/avatar/v1/defaultunlocked avatar-inventory
+GET api/avatar/v1/lockeditems avatar-inventory
+GET api/avatar/v2 avatar-inventory
+GET api/avatar/v2/gifts avatar-inventory
+POST api/avatar/v2/gifts/generate avatar-inventory
+POST api/avatar/v2/set avatar-inventory
+GET api/avatar/v2/{playerId} avatar-inventory
+POST api/avatar/v3/gifts/generate avatar-inventory
+GET api/avatar/v3/saved avatar-inventory
+POST api/avatar/v3/saved/set avatar-inventory
+GET api/avatar/v4/items avatar-inventory
+GET api/banappeal/generateCode moderation-safety
+GET api/catalog/v1/all?onlyAvailableSkus=true avatar-inventory
+GET api/challenge/v2/getCurrent progression-misc
+POST api/challenge/v2/updateProgress progression-misc
+POST api/checklist/v1/complete progression-misc
+GET api/checklist/v1/current progression-misc
+POST api/clubreporting/v1/report moderation-safety
+GET api/communityboard/v2/current progression-misc
+GET api/config/v1/amplitude config-telemetry
+GET api/config/v1/azurespeech config-telemetry
+GET api/config/v1/backtrace config-telemetry
+GET api/config/v1/cohortnux/{cohortId} config-telemetry
+GET api/config/v1/freegiftbutton config-telemetry
+GET api/config/v2 config-telemetry
+POST api/consumables/v1/consume avatar-inventory
+GET api/consumables/v1/getTransferable/{playerId} avatar-inventory
+POST api/consumables/v1/transfer avatar-inventory
+POST api/consumables/v1/updateActive avatar-inventory
+GET api/consumables/v2/getUnlocked avatar-inventory
+POST api/customAvatarItems/v1 avatar-inventory
+POST api/customAvatarItems/v1/bulk avatar-inventory
+DELETE api/customAvatarItems/v1/design avatar-inventory
+GET api/customAvatarItems/v1/design avatar-inventory
+PUT api/customAvatarItems/v1/design avatar-inventory
+GET api/customAvatarItems/v1/featured avatar-inventory
+GET api/customAvatarItems/v1/hot avatar-inventory
+GET api/customAvatarItems/v1/isCreationAllowedForAccount avatar-inventory
+GET api/customAvatarItems/v1/isCreationEnabled avatar-inventory
+GET api/customAvatarItems/v1/isRenderingEnabled avatar-inventory
+GET api/customAvatarItems/v1/me avatar-inventory
+GET api/customAvatarItems/v1/minPriceForPublicItem avatar-inventory
+DELETE api/customAvatarItems/v1/{id} avatar-inventory
+PUT api/customAvatarItems/v1/{id} avatar-inventory
+POST api/customAvatarItems/v1/{id}/report avatar-inventory
+GET api/customAvatarItems/v2/fromCreator/{creatorId} avatar-inventory
+POST api/equipment/v1/update avatar-inventory
+GET api/equipment/v2/getUnlocked avatar-inventory
+POST api/externalfriendinvite/v1/createplatforminvite relationships-messaging
+POST api/externalfriendinvite/v1/getplatformreferrers relationships-messaging
+POST api/externalfriendinvite/v1/gettextmessagereferrers relationships-messaging
+POST api/externalfriendinvite/v1/sendtextmessageinvite relationships-messaging
+POST api/freegifts/v1/sendmultiple progression-misc
+GET api/gameconfigs/v1/all config-telemetry
+GET api/gamerewards/v1/pending progression-misc
+POST api/gamerewards/v1/request progression-misc
+POST api/gamerewards/v1/select progression-misc
+POST api/gamesight/event events-playlists
+POST api/images/v1/cheer inventions-media
+POST api/images/v1/deletesaved inventions-media
+GET api/images/v1/listsaved inventions-media
+POST api/images/v1/modifyaccessibility inventions-media
+POST api/images/v1/sendlink inventions-media
+GET api/images/v1/slideshow inventions-media
+POST api/images/v1/{id}/report inventions-media
+GET api/images/v2/named inventions-media
+GET api/images/v4/room/{roomId}?sort&filter&take&skip inventions-media
+POST api/images/v4/uploadsaved inventions-media
+GET api/images/v5/bulk inventions-media
+GET api/images/v5/cheered/bulk inventions-media
+GET api/images/v5/player/{playerId}?sort={int} inventions-media
+GET api/images/v6/{id} inventions-media
+GET api/images/v6?name={imageName} inventions-media
+POST api/incentivizedreferrals/claim progression-misc
+GET api/incentivizedreferrals/progress progression-misc
+GET api/influencerpartnerprogram/influencer progression-misc
+GET api/influencerpartnerprogram/influencers progression-misc
+GET api/influencerpartnerprogram/myinfluencer progression-misc
+POST api/influencerpartnerprogram/remove progression-misc
+POST api/influencerpartnerprogram/support progression-misc
+POST api/inventions/v1/cheer inventions-media
+GET api/inventions/v1/delete?inventionId={id} inventions-media
+GET api/inventions/v1/details?inventionId={id} inventions-media
+GET api/inventions/v1/dormskinsfromids inventions-media
+GET api/inventions/v1/featured inventions-media
+GET api/inventions/v1/featureddormskins inventions-media
+GET api/inventions/v1/fromcreators inventions-media
+GET api/inventions/v1/fulllineageowner inventions-media
+GET api/inventions/v1/personaldetails/{id} inventions-media
+POST api/inventions/v1/report inventions-media
+GET api/inventions/v1/room?id={roomId} inventions-media
+POST api/inventions/v1/settags inventions-media
+GET api/inventions/v1/tagfilters inventions-media
+GET api/inventions/v1/toptoday inventions-media
+GET api/inventions/v1/unpublish?inventionId={id} inventions-media
+GET api/inventions/v1/update inventions-media
+POST api/inventions/v1/updateprice inventions-media
+GET api/inventions/v1/version?inventionId={id}&version={n} inventions-media
+GET api/inventions/v1/versions?inventionId={id} inventions-media
+GET api/inventions/v1?inventionId={id} inventions-media
+GET api/inventions/v2/batch inventions-media
+GET api/inventions/v2/mine inventions-media
+GET api/inventions/v2/search?value&skip&take inventions-media
+GET api/inventions/v3/publish?inventionId&permissionLevel&accessibility&price inventions-media
+POST api/inventions/v4/addversion inventions-media
+POST api/inventions/v6/save inventions-media
+POST api/keepsakes events-playlists
+GET api/keepsakes/categories events-playlists
+GET api/keepsakes/events/{progressionEventId} events-playlists
+GET api/keepsakes/globalconfig events-playlists
+GET api/keepsakes/rooms/{roomId} events-playlists
+DELETE api/keepsakes/{keepsakeInstanceId:guid} events-playlists
+POST api/keepsakes/{keepsakeInstanceId:guid}/collect events-playlists
+GET api/messages/v1/favoriteFriendOnlineStatus relationships-messaging
+POST api/messages/v1/sendMultiple relationships-messaging
+GET api/messages/v2/get relationships-messaging
+POST api/messages/v2/send relationships-messaging
+POST api/messages/v3/delete relationships-messaging
+POST api/objectives/v1/cleargroup progression-misc
+POST api/objectives/v1/completegroup progression-misc
+GET api/objectives/v1/myprogress progression-misc
+POST api/objectives/v1/updateobjective progression-misc
+POST api/offlineinvite/v1/send relationships-messaging
+GET api/playerevents/v1 events-playlists
+GET api/playerevents/v1/all events-playlists
+GET api/playerevents/v1/all/{0} events-playlists
+POST api/playerevents/v1/broadcast events-playlists
+POST api/playerevents/v1/bulk events-playlists
+POST api/playerevents/v1/bulkInvite events-playlists
+GET api/playerevents/v1/club/{0} events-playlists
+GET api/playerevents/v1/clubs events-playlists
+POST api/playerevents/v1/deleteResponse events-playlists
+POST api/playerevents/v1/report events-playlists
+POST api/playerevents/v1/respond events-playlists
+GET api/playerevents/v1/room/{0} events-playlists
+GET api/playerevents/v1/search events-playlists
+GET api/playerevents/v1/searchlive events-playlists
+GET api/playerevents/v1/tagfilters events-playlists
+GET api/playerevents/v1/{0} events-playlists
+GET api/playerevents/v1/{0}/responses events-playlists
+POST api/playerevents/v2 events-playlists
+POST api/playerevents/v2/delete/{0} events-playlists
+POST api/playerevents/v2/{0} events-playlists
+PUT api/playerevents/v2/{0}/accessibility events-playlists
+PUT api/playerevents/v2/{0}/club events-playlists
+PUT api/playerevents/v2/{0}/description events-playlists
+PUT api/playerevents/v2/{0}/image events-playlists
+PUT api/playerevents/v2/{0}/multiinstance events-playlists
+PUT api/playerevents/v2/{0}/name events-playlists
+PUT api/playerevents/v2/{0}/room events-playlists
+PUT api/playerevents/v2/{0}/tags events-playlists
+PUT api/playerevents/v2/{0}/time events-playlists
+PUT api/players/v4/current/contact identity-account
+POST api/playerwarnings moderation-safety
+POST api/playerwarnings/acknowledge moderation-safety
+POST api/playstationplus/expire commerce-subscription
+GET api/progressionEvents/active events-playlists
+POST api/progressionEvents/collect/{eventId}/{rewardIndex} events-playlists
+GET api/progressionEvents/event/{eventId} events-playlists
+GET api/progressionEvents/record/{progressionEventId} events-playlists
+GET api/progressionEvents/{eventId}/xpboosts events-playlists
+GET api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp events-playlists
+GET api/quickPlay/v1/getandclear progression-misc
+GET api/relationships/mutualfriends relationships-messaging
+POST api/relationships/sendfriendintroductions relationships-messaging
+GET api/relationships/v1/addfriendwithcode relationships-messaging
+POST api/relationships/v1/bulkignoreplatformusers relationships-messaging
+GET api/relationships/v1/favorite relationships-messaging
+POST api/relationships/v1/ignore moderation-safety
+POST api/relationships/v1/mute moderation-safety
+GET api/relationships/v1/unfavorite relationships-messaging
+POST api/relationships/v1/unignore moderation-safety
+POST api/relationships/v1/unmute moderation-safety
+GET api/relationships/v2/acceptfriendrequest relationships-messaging
+GET api/relationships/v2/addfriend relationships-messaging
+GET api/relationships/v2/get relationships-messaging
+GET api/relationships/v2/removefriend relationships-messaging
+GET api/relationships/v2/sendfriendrequest relationships-messaging
+POST api/roomCurrencies/v2/purchase room-economy
+POST api/roomEarningsDistributions/v1/earningsDistribution progression-misc
+PUT api/roomconsumables/v1/roomConsumable room-economy
+GET api/roomconsumables/v1/roomConsumable/room/{roomId} room-economy
+GET api/roomconsumables/v1/roomConsumable/room/{roomId}/me room-economy
+DELETE api/roomconsumables/v1/roomConsumable/{id} room-economy
+PUT api/roomconsumables/v1/roomConsumable/{id}/consume room-economy
+GET api/roomconsumables/v1/roomConsumable/{id}/isOwned room-economy
+PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/currency room-economy
+PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/tokens room-economy
+POST api/roomcurrencies/v1/awardCurrency/bulk room-economy
+POST api/roomcurrencies/v1/createCurrency room-economy
+POST api/roomcurrencies/v1/createPurchaseOffer room-economy
+GET api/roomcurrencies/v1/currencies room-economy
+POST api/roomcurrencies/v1/deletePurchaseOffer room-economy
+GET api/roomcurrencies/v1/getAllBalances room-economy
+GET api/roomcurrencies/v1/getBalance room-economy
+GET api/roomcurrencies/v1/getPurchaseOffersBatch room-economy
+POST api/roomcurrencies/v1/updateCurrency room-economy
+POST api/roomcurrencies/v1/updatePurchaseOffer room-economy
+POST api/roomkeys/v1/create room-economy
+DELETE api/roomkeys/v1/delete/{roomKeyId} room-economy
+GET api/roomkeys/v1/mine room-economy
+GET api/roomkeys/v1/owns room-economy
+POST api/roomkeys/v1/owns/bulk room-economy
+GET api/roomkeys/v1/purchased/{roomKeyId} room-economy
+GET api/roomkeys/v1/room?roomId={id} room-economy
+GET api/rooms/v1/filters rooms
+POST api/rooms/v1/verifyRole rooms
+POST api/rooms/v2/report rooms
+GET api/royale/v1/current progression-misc
+POST api/royale/v2/matchcomplete progression-misc
+POST api/sanitize/v1 identity-account
+POST api/sanitize/v1/isPure identity-account
+POST api/screensharereports/v1/report moderation-safety
+POST api/storefronts/v1/PurchaseRoomKeyWithCurrency commerce-subscription
+GET api/storefronts/v1/adcarouselitems commerce-subscription
+GET api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int} commerce-subscription
+POST api/storefronts/v1/buyForFreeGiftButton commerce-subscription
+POST api/storefronts/v1/buyProgressionEventXpBoost commerce-subscription
+POST api/storefronts/v1/buyPurchaseReminder commerce-subscription
+GET api/storefronts/v1/buyRoomKey commerce-subscription
+POST api/storefronts/v1/objectives commerce-subscription
+GET api/storefronts/v1/season/{storefrontType:int} commerce-subscription
+GET api/storefronts/v1/toptoday commerce-subscription
+POST api/storefronts/v1/trialInvention commerce-subscription
+GET api/storefronts/v1/trialInvention/duration commerce-subscription
+POST api/storefronts/v2/balance commerce-subscription
+POST api/storefronts/v2/buyElite commerce-subscription
+GET api/storefronts/v2/buyInvention commerce-subscription
+POST api/storefronts/v2/buyItem commerce-subscription
+POST api/storefronts/v2/buyTier commerce-subscription
+GET api/storefronts/v3/giftdropstore/{storefrontType:int} commerce-subscription
+GET api/storefronts/v4/balance/{currencyType:int} commerce-subscription
+GET api/subscriptionseasons/v1/seasons/current progression-misc
+GET api/testcasemanagement/v1/testcase/{id} config-telemetry
+POST api/testcasemanagement/v1/testcase/{id}/claim config-telemetry
+POST api/testcasemanagement/v1/testcase/{id}/comment config-telemetry
+POST api/testcasemanagement/v1/testcase/{id}/status config-telemetry
+POST api/testcasemanagement/v1/testcase/{id}/unclaim config-telemetry
+GET api/testcasemanagement/v1/testpass/{id} config-telemetry
+GET api/testcasemanagement/v1/testpasssummary config-telemetry
+POST api/ugcPurchasables/v1/items/bulk avatar-inventory
+GET api/ugcPurchasables/v1/items/room/{roomId} avatar-inventory
+GET api/versioncheck/v4 identity-account
+DELETE cachedlogin/current identity-account
+GET cachedlogin/forplatformid/{0}/{1} identity-account
+POST cachedlogin/forplatformids identity-account
+POST cachedlogin/migrate identity-account
+GET club/account/{0}/created social-clubs-chat
+GET club/categoryTags social-clubs-chat
+POST club/create social-clubs-chat
+DELETE club/home/me social-clubs-chat
+GET club/home/me social-clubs-chat
+PUT club/home/me social-clubs-chat
+GET club/mine/created social-clubs-chat
+GET club/mine/member social-clubs-chat
+GET club/mostactivetoday social-clubs-chat
+GET club/search social-clubs-chat
+DELETE club/{0} social-clubs-chat
+GET club/{0} social-clubs-chat
+DELETE club/{0}/additionalimage/{1} social-clubs-chat
+PUT club/{0}/additionalimage/{1} social-clubs-chat
+PUT club/{0}/clubChatEnabled social-clubs-chat
+DELETE club/{0}/clubhouse social-clubs-chat
+PUT club/{0}/clubhouse social-clubs-chat
+GET club/{0}/details social-clubs-chat
+GET club/{0}/hasDisabledClubChat social-clubs-chat
+DELETE club/{0}/mainimage social-clubs-chat
+PUT club/{0}/mainimage social-clubs-chat
+GET club/{0}/members social-clubs-chat
+POST club/{0}/members/acceptinvite social-clubs-chat
+POST club/{0}/members/acceptrequest social-clubs-chat
+POST club/{0}/members/acceptrequests social-clubs-chat
+POST club/{0}/members/ban social-clubs-chat
+GET club/{0}/members/banned social-clubs-chat
+PUT club/{0}/members/changetype social-clubs-chat
+POST club/{0}/members/declineinvite social-clubs-chat
+POST club/{0}/members/denyrequest social-clubs-chat
+POST club/{0}/members/denyrequests social-clubs-chat
+POST club/{0}/members/directJoin social-clubs-chat
+PUT club/{0}/members/invite social-clubs-chat
+POST club/{0}/members/invitemembers social-clubs-chat
+POST club/{0}/members/leave social-clubs-chat
+POST club/{0}/members/remove social-clubs-chat
+GET club/{0}/members/requests social-clubs-chat
+GET club/{0}/members/requests/search social-clubs-chat
+PUT club/{0}/members/requesttojoin social-clubs-chat
+GET club/{0}/members/search social-clubs-chat
+POST club/{0}/members/unban social-clubs-chat
+GET club/{0}/members/{1} social-clubs-chat
+PUT club/{0}/minlevel social-clubs-chat
+PUT club/{0}/modify social-clubs-chat
+PUT club/{0}/modifydetails social-clubs-chat
+PUT club/{0}/permissions/{1} social-clubs-chat
+GET clubhousesearch/mostactivenow social-clubs-chat
+POST comments/create/{0} social-clubs-chat
+DELETE comments/delete/{0} social-clubs-chat
+GET comments/get/{0} social-clubs-chat
+PUT comments/read/{0}/{1} social-clubs-chat
+GET comments/unreadcounts social-clubs-chat
+GET config/categories config-telemetry
+POST connect/deviceauthorization identity-account
+POST connect/token identity-account
+POST data/event events-playlists
+PUT datalink config-telemetry
+GET datalink/{code} config-telemetry
+GET eac/challenge identity-account
+GET econ/customAvatarItems/v1/itemOwnershipLimit avatar-inventory
+GET econ/customAvatarItems/v1/owned avatar-inventory
+POST econ/customAvatarItems/v1/{id}/purchase/?requestedPrice={price} avatar-inventory
+GET emojiConfig/whitelistedEmojis config-telemetry
+GET event/{0}/instances events-playlists
+GET featuredrooms/current rooms
+POST feed/query config-telemetry
+DELETE invite/{0} progression-misc
+POST leaderboard/CheckAndSetStat progression-misc
+POST leaderboard/GetNearbyScores progression-misc
+POST leaderboard/GetPlayerRank progression-misc
+POST leaderboard/GetRanks progression-misc
+POST matchmake/chatinvite/{0}/{1} matchmaking-session
+POST matchmake/club/{0} matchmaking-session
+POST matchmake/code/{0}/{1} matchmaking-session
+POST matchmake/dorm matchmaking-session
+POST matchmake/event/{0} matchmaking-session
+POST matchmake/instance/{0} matchmaking-session
+POST matchmake/invite/{0} matchmaking-session
+POST matchmake/none matchmaking-session
+POST matchmake/player/{0} matchmaking-session
+POST matchmake/room/{0} matchmaking-session
+POST matchmake/room/{0}/{1} matchmaking-session
+GET members/bulk social-clubs-chat
+GET namegen/options identity-account
+POST pageview/consume config-telemetry
+GET parentalcontrol/me identity-account
+GET platformid/{0} identity-account
+GET player/avoidjuniors matchmaking-session
+POST player/exclusivelogin matchmaking-session
+POST player/heartbeat matchmaking-session
+POST player/login matchmaking-session
+POST player/logout matchmaking-session
+POST player/notifydisconnect matchmaking-session
+PUT player/photonregionpings matchmaking-session
+PUT player/statusvisibility matchmaking-session
+PUT player/vrmovementmode matchmaking-session
+POST playlists/bulk events-playlists
+GET playlists/cheeredby/me events-playlists
+GET playlists/createdby/me events-playlists
+GET playlists/favoritedby/me events-playlists
+GET playlists/visitedby/me events-playlists
+DELETE playlists/{0} events-playlists
+GET playlists/{0} events-playlists
+PUT playlists/{0}/accessibility events-playlists
+PUT playlists/{0}/description events-playlists
+PUT playlists/{0}/image events-playlists
+GET playlists/{0}/interactionby/me events-playlists
+PUT playlists/{0}/interactionby/me/cheer events-playlists
+PUT playlists/{0}/interactionby/me/favorite events-playlists
+PUT playlists/{0}/levelvoting events-playlists
+PUT playlists/{0}/name events-playlists
+PUT playlists/{0}/restrictions events-playlists
+PUT playlists/{0}/rooms/{1} events-playlists
+PUT playlists/{0}/tags events-playlists
+PUT playlists/{0}/warning events-playlists
+POST purchase/v1/cancelpurchase commerce-subscription
+POST purchase/v1/cleanuppending commerce-subscription
+POST purchase/v1/completepurchase commerce-subscription
+GET purchase/v1/hasspentmoney commerce-subscription
+POST purchase/v1/initiatepurchase commerce-subscription
+POST purchase/v1/processpurchase commerce-subscription
+GET purchasecampaign/allcurrent/v2 commerce-subscription
+POST purchasecampaign/shown commerce-subscription
+GET reminder/currentTokenBundles/v2 commerce-subscription
+POST remote-run/push-to-studio inventions-media
+GET role/developer/{0} progression-misc
+GET role/moderator/{0} progression-misc
+GET room/{0}/instances rooms
+PUT roominstance/{0}/inprogress rooms
+PUT roominstance/{0}/markprivate rooms
+POST roominstance/{0}/reportjoinresult rooms
+PUT roominstance/{0}/roomCode rooms
+GET rooms/base rooms
+GET rooms/bulk rooms
+GET rooms/cheeredby/me rooms
+GET rooms/contestwinners rooms
+GET rooms/createdby/me rooms
+GET rooms/createdby/{0} rooms
+GET rooms/curated_playlists rooms
+GET rooms/favoritedby/me rooms
+GET rooms/fromcreators rooms
+GET rooms/hot rooms
+GET rooms/magic_door rooms
+GET rooms/moderatedby/me rooms
+GET rooms/ownedby/me rooms
+GET rooms/ownedby/{0} rooms
+GET rooms/recommendations rooms
+GET rooms/rro_ids rooms
+GET rooms/search rooms
+GET rooms/topcreators rooms
+GET rooms/visitedby/me rooms
+GET rooms/visitedby/{0} rooms
+DELETE rooms/{0} rooms
+GET rooms/{0} rooms
+PUT rooms/{0}/accessibility rooms
+PUT rooms/{0}/allow_new_users rooms
+PUT rooms/{0}/automute rooms
+GET rooms/{0}/bans rooms
+POST rooms/{0}/bans rooms
+POST rooms/{0}/bans/import rooms
+DELETE rooms/{0}/bans/{1} rooms
+POST rooms/{0}/clone rooms
+PUT rooms/{0}/cloning rooms
+PUT rooms/{0}/comments rooms
+PUT rooms/{0}/creator rooms
+PUT rooms/{0}/description rooms
+PUT rooms/{0}/image rooms
+GET rooms/{0}/interactionby/me rooms
+DELETE rooms/{0}/interactionby/me/cheer rooms
+PUT rooms/{0}/interactionby/me/cheer rooms
+DELETE rooms/{0}/interactionby/me/favorite rooms
+PUT rooms/{0}/interactionby/me/favorite rooms
+DELETE rooms/{0}/loadscreen rooms
+PUT rooms/{0}/loadscreen rooms
+PUT rooms/{0}/max_player_calculation_mode rooms
+PUT rooms/{0}/min_level rooms
+PUT rooms/{0}/modify rooms
+PUT rooms/{0}/name rooms
+GET rooms/{0}/playerdata/me rooms
+PUT rooms/{0}/playerdata/me rooms
+DELETE rooms/{0}/promo_external rooms
+PUT rooms/{0}/promo_external rooms
+DELETE rooms/{0}/promo_images rooms
+PUT rooms/{0}/promo_images rooms
+PUT rooms/{0}/restrictions rooms
+GET rooms/{0}/roles rooms
+GET rooms/{0}/roles/{1} rooms
+PUT rooms/{0}/roles/{1} rooms
+PUT rooms/{0}/roles/{1}/invite rooms
+GET rooms/{0}/similar rooms
+POST rooms/{0}/subrooms rooms
+DELETE rooms/{0}/subrooms/{1} rooms
+PUT rooms/{0}/subrooms/{1}/accessibility rooms
+POST rooms/{0}/subrooms/{1}/clone rooms
+POST rooms/{0}/subrooms/{1}/data rooms
+PUT rooms/{0}/subrooms/{1}/maxplayers rooms
+PUT rooms/{0}/subrooms/{1}/modify rooms
+POST rooms/{0}/subrooms/{1}/move rooms
+PUT rooms/{0}/subrooms/{1}/name rooms
+PUT rooms/{0}/subrooms/{1}/permissions rooms
+POST rooms/{0}/subrooms/{1}/publish_save rooms
+GET rooms/{0}/subrooms/{1}/saves rooms
+PUT rooms/{0}/tags rooms
+PUT rooms/{0}/voice_chat_encryption rooms
+PUT rooms/{0}/warning rooms
+GET settings/partyinvite config-telemetry
+GET showcase/{0} inventions-media
+GET subscription/details/{accountId:int} commerce-subscription
+GET subscription/mine/member commerce-subscription
+GET subscription/subscriberCount/{accountId:int} commerce-subscription
+GET subscription/top/creators/today commerce-subscription
+POST subscription/v1/cancel commerce-subscription
+POST subscription/{accountId:int} commerce-subscription
+GET thread/club/{0} social-clubs-chat
+PUT thread/message/{0}/moderate social-clubs-chat
+POST thread/withmembers social-clubs-chat
+GET thread/{0} social-clubs-chat
+POST thread/{0} social-clubs-chat
+PUT thread/{0}/favorite social-clubs-chat
+POST thread/{0}/leave social-clubs-chat
+POST thread/{0}/member/{1} social-clubs-chat
+GET thread/{0}/message social-clubs-chat
+POST thread/{0}/message/{1}/read social-clubs-chat
+POST thread/{0}/rename social-clubs-chat
+POST thread/{0}/snooze social-clubs-chat
+GET unity_assets/{0}/{1}/{2} inventions-media
+GET voice/config moderation-safety
+GET voice/requiresModeration moderation-safety
diff --git a/DorkNet.Server/Binding/FormOrJsonModelBinder.cs b/DorkNet.Server/Binding/FormOrJsonModelBinder.cs
new file mode 100644
index 0000000..c55123b
--- /dev/null
+++ b/DorkNet.Server/Binding/FormOrJsonModelBinder.cs
@@ -0,0 +1,142 @@
+using System.Reflection;
+using System.Text.Json;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace DorkNet.Server.Binding;
+
+///
+/// Binds an action's request DTO from either a form-urlencoded body or a JSON
+/// body, whichever the caller actually sent.
+///
+/// The Rec Room client's HTTP layer (BNDIAONDFFF in RecNet.Runtime)
+/// sends nearly every write as application/x-www-form-urlencoded, adding
+/// fields one at a time. Handlers written against [FromBody] only accept
+/// JSON, so with [ApiController] those requests were rejected with 415
+/// Unsupported Media Type before the action body ever ran — this is why every
+/// club member-management action (invite, ban, kick, promote, approve, modify)
+/// failed on the 2023 client.
+///
+/// Rather than hand-rolling a form reader per handler, apply this binder to the
+/// DTO parameter:
+///
+///
+/// public Task<IActionResult> MemberBan(
+/// long clubId,
+/// [ModelBinder(typeof(FormOrJsonModelBinder))] MemberTargetRequest req)
+///
+///
+/// The explicit [ModelBinder] is required: without it [ApiController]
+/// infers [FromBody] for complex types and this binder never runs.
+///
+/// Matching is case-insensitive on both paths, and a scalar property also
+/// accepts the singular/plural spellings the client uses interchangeably. A
+/// missing or unparseable body yields a default-constructed DTO rather than a
+/// 400, matching how the client treats these calls as fire-and-forget.
+///
+public sealed class FormOrJsonModelBinder : IModelBinder
+{
+ public async Task BindModelAsync(ModelBindingContext bindingContext)
+ {
+ ArgumentNullException.ThrowIfNull(bindingContext);
+
+ var type = bindingContext.ModelType;
+ var request = bindingContext.HttpContext.Request;
+ var model = Activator.CreateInstance(type)!;
+
+ if (request.HasFormContentType)
+ {
+ var form = await request.ReadFormAsync(bindingContext.HttpContext.RequestAborted);
+ foreach (var prop in Writable(type))
+ {
+ var values = Lookup(form, prop.Name);
+ if (values.Count == 0) continue;
+ var converted = Convert(values, prop.PropertyType);
+ if (converted is not null) prop.SetValue(model, converted);
+ }
+ }
+ else
+ {
+ try
+ {
+ request.EnableBuffering();
+ request.Body.Position = 0;
+ var parsed = await JsonSerializer.DeserializeAsync(
+ request.Body, type,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true },
+ bindingContext.HttpContext.RequestAborted);
+ request.Body.Position = 0;
+ if (parsed is not null) model = parsed;
+ }
+ catch (JsonException) { /* empty or non-JSON body → defaults */ }
+ catch (NotSupportedException) { /* unreadable body → defaults */ }
+ }
+
+ bindingContext.Result = ModelBindingResult.Success(model);
+ }
+
+ private static IEnumerable Writable(Type type) =>
+ type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(p => p.CanWrite);
+
+ /// Form keys, case-insensitively, also accepting the other
+ /// number of the name — the client says accountId where the DTO says
+ /// AccountIds and vice versa depending on the endpoint.
+ private static List Lookup(IFormCollection form, string propertyName)
+ {
+ var candidates = new List { propertyName };
+ if (propertyName.EndsWith('s')) candidates.Add(propertyName[..^1]);
+ else candidates.Add(propertyName + "s");
+
+ foreach (var candidate in candidates)
+ {
+ foreach (var key in form.Keys)
+ {
+ if (!string.Equals(key, candidate, StringComparison.OrdinalIgnoreCase)) continue;
+ var values = form[key]
+ .Where(v => !string.IsNullOrWhiteSpace(v))
+ .Select(v => v!)
+ .ToList();
+ if (values.Count > 0) return values;
+ }
+ }
+ return [];
+ }
+
+ private static object? Convert(List values, Type target)
+ {
+ var underlying = Nullable.GetUnderlyingType(target) ?? target;
+
+ if (underlying.IsGenericType && underlying.GetGenericTypeDefinition() == typeof(List<>))
+ {
+ var element = underlying.GetGenericArguments()[0];
+ var list = (System.Collections.IList)Activator.CreateInstance(underlying)!;
+ // A repeated field arrives as multiple values; a single field may
+ // still carry a comma-separated list.
+ foreach (var raw in values.SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)))
+ {
+ var item = ConvertScalar(raw, element);
+ if (item is not null) list.Add(item);
+ }
+ return list.Count > 0 ? list : null;
+ }
+
+ return ConvertScalar(values[0], underlying);
+ }
+
+ private static object? ConvertScalar(string raw, Type target)
+ {
+ try
+ {
+ if (target == typeof(string)) return raw;
+ if (target == typeof(bool)) return bool.TryParse(raw, out var b) ? b : raw == "1";
+ if (target.IsEnum) return Enum.TryParse(target, raw, ignoreCase: true, out var e) ? e : null;
+ if (target == typeof(Guid)) return Guid.TryParse(raw, out var g) ? g : null;
+ if (target == typeof(DateTime)) return DateTime.TryParse(raw, out var d) ? d : null;
+ return System.Convert.ChangeType(raw, target, System.Globalization.CultureInfo.InvariantCulture);
+ }
+ catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/DorkNet.Server/Controllers/API/AgeVerification/AgeVerificationController.cs b/DorkNet.Server/Controllers/API/AgeVerification/AgeVerificationController.cs
index 2c31906..867283e 100644
--- a/DorkNet.Server/Controllers/API/AgeVerification/AgeVerificationController.cs
+++ b/DorkNet.Server/Controllers/API/AgeVerification/AgeVerificationController.cs
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
+using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
@@ -13,6 +14,13 @@ namespace DorkNet.Server.Controllers.API.AgeVerification;
[Authorize]
public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
{
+ /// The 2023-03-21 client POSTs this (verb register rdx=2
+ /// at RecNet.Runtime/ALLHCFFLMLD.txt:216-222) and deserialises the body as
+ /// a bare String: the issuing method is
+ /// FGLDKEJLAKB<IPCJLCNIBEG<System.String>> GKEBPOPAEJC()
+ /// (:150). Returning a JSON object here made Json.NET throw, so the
+ /// promise rejected and the code was never shown in the watch.
+ /// GET stays registered because it costs nothing.
[HttpPost("generateCode")]
[HttpGet("generateCode")]
public async Task GenerateCode()
@@ -26,6 +34,6 @@ public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
Value = code,
});
await db.SaveChangesAsync();
- return Ok(new { Code = code, VerificationCode = code, ExpiresAt = DateTime.UtcNow.AddMinutes(15) });
+ return Content(JsonSerializer.Serialize(code), "application/json");
}
}
diff --git a/DorkNet.Server/Controllers/API/Comments/CommentsController.cs b/DorkNet.Server/Controllers/API/Comments/CommentsController.cs
index ad355e2..54da8c8 100644
--- a/DorkNet.Server/Controllers/API/Comments/CommentsController.cs
+++ b/DorkNet.Server/Controllers/API/Comments/CommentsController.cs
@@ -1,36 +1,470 @@
+using System.Globalization;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Comments;
///
-/// Room-comment unread counts. Client contract (RecNet.Runtime
-/// DJLNMANKMAA(IReadOnlyCollection<long>)): the caller sends a
-/// collection of roomIds and expects a top-level JSON OBJECT
-/// Dictionary<long, uint> mapping roomId → unread count.
-/// We don't track per-player comment read-state yet, so every requested
-/// room maps to 0 — a correct, non-crashing "nothing unread" answer that
-/// matches the wire shape (an object, never an array).
+/// Room comments ("feedback"/"idea" notes players leave in a room, shown by
+/// the watch's RoomCommentsScreen / RoomCommentsPagedGrid).
+///
+/// Every route here is issued by RecNet.Runtime's room-comment service
+/// OPELMBNJHNO against service host 12 = RoomComments
+/// ( maps that to the roomcomments
+/// subdomain). The five literals, with the BNDIAONDFFF ctor verb
+/// ordinal moved into rdx immediately before the builder call
+/// (BestHTTP.HTTPMethods: 0=GET, 2=POST, 3=PUT, 4=DELETE):
+///
+///
+/// OPELMBNJHNO.txt:485 "comments/get/{0}" — rdx=0 (GET), host [rdx+12]
+/// OPELMBNJHNO.txt:811 "comments/unreadcounts" — dynamic GET/POST
+/// OPELMBNJHNO.txt:1216 "comments/create/{0}" — rdx=2 (POST), host [r12+12]
+/// OPELMBNJHNO.txt:1627 "comments/read/{0}/{1}" — rdx=3 (PUT), host [rbx+12]
+/// OPELMBNJHNO.txt:1935 "comments/delete/{0}" — rdx=4 (DELETE), r8=12
+///
+///
+/// STORAGE. There is no RoomCommentEntity and adding entities
+/// needs a migration, so comments live in the
+/// side-table exactly like the PlayerEvent extras
+/// (PlayerEventsController.EventExtras): one row per comment with
+/// PlayerId = the author, Key = roomcomment:{roomId}:{commentId}
+/// and Value = the JSON payload. Per-player read state is a second,
+/// disjoint namespace — roomcommentread:{roomId} holding the last-read
+/// comment id — which is what finally lets
+/// report real numbers instead of zeroes.
///
[ApiController]
[Authorize]
-public class CommentsController : ControllerBase
+public class CommentsController(DorkNetDbContext db) : ControllerBase
{
+ private long Me => this.RequireCurrentPlayerId();
+
+ /// Key prefix for a stored comment: roomcomment:{roomId}:{commentId}.
+ private const string CommentPrefix = "roomcomment:";
+
+ /// Key prefix for per-player read state: roomcommentread:{roomId},
+ /// value = last-read comment id. Deliberately a different prefix from
+ /// (not roomcomment:read:…) so the
+ /// suffix match used by can never hit a read-state row.
+ private const string ReadPrefix = "roomcommentread:";
+
+ /// The RecNet.RoomComment members that are not already
+ /// implied by the storage row's key/owner. Position is three separate
+ /// nullable floats on the wire, not a Vector3: the client's
+ /// Nullable<Vector3> property is [IgnoreDataMember] and is
+ /// rebuilt after deserialisation from three internal
+ /// Nullable<float> fields (RecNet/RoomComment.txt:495
+ /// FKDDCNLJOLF reads the HasValue byte at [rbx+0x6C], i.e. the
+ /// first of the three at 0x68/0x70/0x78).
+ ///
+ /// Public (not private) so System.Text.Json's reflection-based
+ /// (de)serialiser can see the type and its accessors.
+ public sealed class CommentPayload
+ {
+ public long? SubRoomId { get; set; }
+ public string Message { get; set; } = string.Empty;
+ /// RoomComment.EKHGDABMJOJ: Feedback=0, Idea=1, BugReport=2
+ /// (RecNet/RoomComment.cs enum member order).
+ public int Style { get; set; }
+ public float? X { get; set; }
+ public float? Y { get; set; }
+ public float? Z { get; set; }
+ public DateTime CreatedAt { get; set; }
+ }
+
+ // ── Reads ────────────────────────────────────────────────────────────
+
+ /// Comment list for a room.
+ ///
+ /// OPELMBNJHNO.GMBPJHCBPDP(Int64 roomId, Int32 count, Int64 minId)
+ /// returns FGLDKEJLAKB<List<RecNet.RoomComment>>
+ /// (OPELMBNJHNO.txt:341), so the body is a bare JSON array — not a
+ /// paged {Results,TotalResults} container. Verb is GET (rdx=0 at
+ /// instr 044) and both parameters go on the query string, because
+ /// BNDIAONDFFF.AFGEDDANEKP only promotes fields to a form body for
+ /// non-GET verbs: literals "count" (:506) and "minId" (:518).
+ ///
+ /// minId is a lower bound on the comment id (the only caller,
+ /// FeedbackManager.txt:948, passes count=100 and a carried-forward id), so
+ /// it is applied inclusively and the newest count matches are
+ /// returned newest-first — which is the order the paged grid renders.
+ ///
+ /// Failure toast if this 404s or the body will not parse:
+ /// "Failed to get room comments for room: {0}" (:537).
+ [HttpGet("/comments/get/{roomId:long}")]
+ public async Task Get(long roomId, [FromQuery] int count = 100, [FromQuery] long minId = 0)
+ {
+ if (count <= 0) count = 100;
+ if (count > 500) count = 500;
+
+ var rows = await CommentRowsForRoomAsync(roomId);
+
+ var wire = rows
+ .Where(r => r.CommentId >= minId)
+ .OrderByDescending(r => r.CommentId)
+ .Take(count)
+ .Select(r => ToWire(r.CommentId, roomId, r.AuthorPlayerId, r.Payload, r.Unread))
+ .ToList();
+
+ return Ok(wire);
+ }
+
+ /// Room-comment unread counts. Client contract (RecNet.Runtime
+ /// OPELMBNJHNO.DJLNMANKMAA(IReadOnlyCollection<long>)).
+ ///
+ /// The response is a JSON ARRAY, not an object. The method's return
+ /// type is Dictionary<long,uint>, but that dictionary is built
+ /// client-side: OPELMBNJHNO.txt:889 constructs a
+ /// Func<List<UnreadRoomComments>, Dictionary<long,uint>>
+ /// projection that runs over the deserialised payload. So the wire type is
+ /// List<RecNet.UnreadRoomComments> — each element an object with a
+ /// room id (Int64) and a count (UInt32), per the two properties on
+ /// RecNet/UnreadRoomComments.txt.
+ ///
+ /// This previously returned an object map keyed by room id. Json.NET cannot
+ /// read a JSON object into a List<T>, so every call threw and the
+ /// client logged "Failed to get unread room comment counts"
+ /// (OPELMBNJHNO.txt:937) and fell back to an empty dictionary — the watch's
+ /// room-comment unread badge never appeared.
+ ///
+ /// The element's exact property names are attribute-driven and are not
+ /// recoverable from the ISIL (see the note on key aliasing below), so each
+ /// element carries the plausible spellings side by side. Json.NET matches
+ /// case-insensitively and ignores unknown members, so the extra aliases are
+ /// inert whichever name the DTO actually declares.
+ ///
+ /// Counts are real now that persists per-player read
+ /// state: a room's count is the number of comments authored by somebody
+ /// else whose id is above the caller's last-read id for that room.
+ ///
[HttpPost("/comments/unreadcounts")]
[HttpGet("/comments/unreadcounts")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task UnreadCounts()
{
var roomIds = await ReadRoomIdsAsync();
- // Keys must be strings in JSON; values are the (currently always 0)
- // unread counts.
- var result = new Dictionary();
- foreach (var id in roomIds) result[id.ToString()] = 0;
+ if (roomIds.Count == 0) return Ok(new List>());
+
+ var me = Me;
+ var lastRead = await LastReadAsync(me);
+
+ // Key + owner only: the payload column is irrelevant to counting.
+ var keys = await db.PlayerSettings
+ .Where(s => s.Key.StartsWith(CommentPrefix))
+ .Select(s => new { s.Key, s.PlayerId })
+ .ToListAsync();
+
+ var counts = new Dictionary();
+ foreach (var id in roomIds) counts[id] = 0u;
+
+ foreach (var k in keys)
+ {
+ if (!TrySplitCommentKey(k.Key, out var roomId, out var commentId)) continue;
+ if (!counts.ContainsKey(roomId)) continue;
+ if (k.PlayerId == me) continue; // your own notes are never unread
+ if (commentId <= lastRead.GetValueOrDefault(roomId)) continue;
+ counts[roomId]++;
+ }
+
+ var result = roomIds
+ .Select(id => new Dictionary
+ {
+ ["RoomId"] = id,
+ ["UnreadCount"] = counts[id],
+ ["Count"] = counts[id],
+ })
+ .ToList();
return Ok(result);
}
+ // ── Writes ───────────────────────────────────────────────────────────
+
+ /// Posts a new comment into a room.
+ ///
+ /// OPELMBNJHNO.JHABIKEMJHK(Int64 roomId, Int64 subRoomId, String message,
+ /// RoomComment/EKHGDABMJOJ style, Nullable<Vector3> position)
+ /// (OPELMBNJHNO.txt:940) returns FGLDKEJLAKB<RecNet.RoomComment> —
+ /// a single comment object, not a list and not a status envelope.
+ ///
+ /// Verb is POST (rdx=2 at instr 069), so every AFGEDDANEKP field is
+ /// an application/x-www-form-urlencoded body field, never a query
+ /// param. Field literals, in emission order: "message" (:1234),
+ /// "subRoomId" (:1249, boxed Int64), "style" (:1261, boxed UInt32),
+ /// then — only when the Vector3? has a value (the
+ /// Compare [rdi+12], r12 / JumpIfEqual at :1268) — "positionX"
+ /// (:1292), "positionY" (:1314), "positionZ" (:1336), each formatted with
+ /// Single.ToString(CultureInfo.InvariantCulture) (:1285/1289), which
+ /// is why the three floats are parsed invariantly here rather than left to
+ /// model binding.
+ ///
+ /// Failure toast: "Failed to Create Comment" (:1359).
+ [HttpPost("/comments/create/{roomId:long}")]
+ public async Task Create(
+ long roomId,
+ [FromForm] string? message,
+ [FromForm] long? subRoomId,
+ [FromForm] int style = 0,
+ [FromForm] string? positionX = null,
+ [FromForm] string? positionY = null,
+ [FromForm] string? positionZ = null)
+ {
+ if (!await db.Rooms.AnyAsync(r => r.Id == roomId)) return NotFound();
+
+ message = (message ?? string.Empty).Trim();
+ if (message.Length == 0) return BadRequest();
+ // PlayerSettingEntity.Value is MaxLength(1024) and also carries the
+ // rest of the payload; keep well clear of the column width.
+ if (message.Length > 700) message = message[..700];
+
+ var payload = new CommentPayload
+ {
+ SubRoomId = subRoomId is > 0 ? subRoomId : null,
+ Message = message,
+ Style = style,
+ X = ParseFloat(positionX),
+ Y = ParseFloat(positionY),
+ Z = ParseFloat(positionZ),
+ CreatedAt = DateTime.UtcNow,
+ };
+ // The client only ever sends all three or none; drop a partial set so
+ // the client's Vector3 reassembly (RoomComment.FKDDCNLJOLF) sees a
+ // consistent null.
+ if (payload.X is null || payload.Y is null || payload.Z is null)
+ payload.X = payload.Y = payload.Z = null;
+
+ var me = Me;
+ var commentId = await NextCommentIdAsync();
+
+ db.PlayerSettings.Add(new PlayerSettingEntity
+ {
+ PlayerId = me,
+ Key = $"{CommentPrefix}{roomId}:{commentId}",
+ Value = JsonSerializer.Serialize(payload),
+ });
+ await db.SaveChangesAsync();
+
+ // Authoring a comment implies you have read up to it.
+ await SetLastReadAsync(me, roomId, commentId);
+ await db.SaveChangesAsync();
+
+ return Ok(ToWire(commentId, roomId, me, payload, unread: false));
+ }
+
+ /// Records the newest comment the caller has read in a room, which
+ /// is what clears the watch's unread badge (and what makes
+ /// return non-zero numbers for everyone else).
+ ///
+ /// OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)
+ /// (OPELMBNJHNO.txt:1375) returns the status-only LDGADANDBIO, not a
+ /// DTO — the body is never parsed, only the status code. Verb is PUT
+ /// (rdx=3 at instr 059, OPELMBNJHNO.txt:1637); both ids are path segments
+ /// formatted into "comments/read/{0}/{1}" (:1627) and no field is added, so
+ /// the request carries no body at all.
+ ///
+ /// Failure toast: "Failed to update latest read comment" (:1656).
+ [HttpPut("/comments/read/{roomId:long}/{commentId:long}")]
+ public async Task MarkRead(long roomId, long commentId)
+ {
+ await SetLastReadAsync(Me, roomId, commentId);
+ await db.SaveChangesAsync();
+ return Ok();
+ }
+
+ /// Deletes a comment.
+ ///
+ /// OPELMBNJHNO.NDLOEEHIDED(Int64 commentId) (OPELMBNJHNO.txt:1781)
+ /// returns the status-only LDGADANDBIO. Verb is DELETE
+ /// (rdx=4 at instr 046) and the host ordinal is the only immediate on the
+ /// builder call (r8=12, RoomComments) — see OPELMBNJHNO.txt:1944-1946.
+ /// No fields are attached: the comment id is the whole request.
+ ///
+ /// Because the route carries no room id, the storage key is matched by its
+ /// :{commentId} suffix; read-state rows live under a different
+ /// prefix precisely so they cannot collide here. Only the author, the room
+ /// creator, or an accepted co-owner/moderator of the room may delete —
+ /// anyone else gets 403 and the client raises its "Failed to delete
+ /// comment" toast (:1962).
+ [HttpDelete("/comments/delete/{commentId:long}")]
+ public async Task Delete(long commentId)
+ {
+ var suffix = ":" + commentId.ToString(CultureInfo.InvariantCulture);
+ var row = await db.PlayerSettings
+ .Where(s => s.Key.StartsWith(CommentPrefix) && s.Key.EndsWith(suffix))
+ .FirstOrDefaultAsync();
+ if (row is null) return NotFound();
+ if (!TrySplitCommentKey(row.Key, out var roomId, out _)) return NotFound();
+
+ var me = Me;
+ if (row.PlayerId != me && !await CanModerateRoomAsync(roomId, me)) return Forbid();
+
+ db.PlayerSettings.Remove(row);
+ await db.SaveChangesAsync();
+ return Ok();
+ }
+
+ // ── Wire shape ───────────────────────────────────────────────────────
+
+ /// One RecNet.RoomComment. Member list and CLR types are
+ /// exact (RecNet/RoomComment.cs, cross-checked against the 2023.06.21
+ /// dump.cs:1241576): long comment id, long room id,
+ /// long? sub-room id, int? account id, DateTime
+ /// created-at, string message, the EKHGDABMJOJ style enum, a
+ /// settable bool, three internal float? position components,
+ /// and one computed get-only bool that is never on the wire.
+ ///
+ /// KEY NAMES. The 2023 DTOs' JSON names live in DataMember attribute blobs
+ /// in global-metadata.dat and are not recoverable from Cpp2IL output, so
+ /// the names below are taken from the client's own request field
+ /// literals for the same values — "message", "subRoomId", "style",
+ /// "positionX", "positionY", "positionZ" (OPELMBNJHNO.txt:1234-1336) —
+ /// which mirror the server DTO they were serialised from. Json.NET matches
+ /// case-insensitively and ignores unknown members, so the aliases for the
+ /// two members with no request-side counterpart are inert.
+ ///
+ /// The settable bool is the unread flag: its only mutator
+ /// RoomComment.MCCJIBCJEJC clears the byte at [rcx+0x64] and
+ /// fires the change event (RoomComment.txt:471-493), and the computed
+ /// get-only bool FOOFBPEMFCB is AccountId == localAccountId ||
+ /// !flag (:442-466, comparing against
+ /// OPMAPIOEIFG.GGAEMNHEHLO). Its declared name is unknown, hence the
+ /// three non-inverting spellings.
+ private static Dictionary ToWire(
+ long commentId, long roomId, long authorPlayerId, CommentPayload p, bool unread) => new(StringComparer.Ordinal)
+ {
+ ["CommentId"] = commentId,
+ ["RoomId"] = roomId,
+ ["SubRoomId"] = p.SubRoomId,
+ ["AccountId"] = (int)authorPlayerId,
+ ["CreatedAt"] = p.CreatedAt,
+ ["Message"] = p.Message,
+ ["Style"] = p.Style,
+ ["PositionX"] = p.X,
+ ["PositionY"] = p.Y,
+ ["PositionZ"] = p.Z,
+ ["IsUnread"] = unread,
+ ["Unread"] = unread,
+ ["IsNew"] = unread,
+ };
+
+ // ── Storage helpers ──────────────────────────────────────────────────
+
+ private sealed record StoredComment(long CommentId, long AuthorPlayerId, CommentPayload Payload, bool Unread);
+
+ /// Every stored comment for a room, with the caller's unread flag
+ /// resolved against their read-state row.
+ private async Task> CommentRowsForRoomAsync(long roomId)
+ {
+ var me = Me;
+ var prefix = $"{CommentPrefix}{roomId}:";
+ var rows = await db.PlayerSettings
+ .Where(s => s.Key.StartsWith(prefix))
+ .Select(s => new { s.Key, s.PlayerId, s.Value })
+ .ToListAsync();
+
+ var lastRead = await LastReadAsync(me);
+ var seen = lastRead.GetValueOrDefault(roomId);
+
+ var list = new List(rows.Count);
+ foreach (var r in rows)
+ {
+ if (!long.TryParse(r.Key[prefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var commentId))
+ continue;
+ CommentPayload? payload;
+ try { payload = JsonSerializer.Deserialize(r.Value); }
+ catch (JsonException) { continue; }
+ if (payload is null) continue;
+ list.Add(new StoredComment(commentId, r.PlayerId, payload, r.PlayerId != me && commentId > seen));
+ }
+ return list;
+ }
+
+ /// Comment ids are handed out from the wall clock in milliseconds
+ /// so they stay globally unique and monotonically increasing across
+ /// restarts — the client's minId filter and the unread comparison
+ /// both rely on "higher id == newer".
+ private async Task NextCommentIdAsync()
+ {
+ var id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ for (var attempt = 0; attempt < 64; attempt++)
+ {
+ var suffix = ":" + id.ToString(CultureInfo.InvariantCulture);
+ var taken = await db.PlayerSettings
+ .AnyAsync(s => s.Key.StartsWith(CommentPrefix) && s.Key.EndsWith(suffix));
+ if (!taken) break;
+ id++;
+ }
+ return id;
+ }
+
+ /// Last-read comment id per room for one player.
+ private async Task> LastReadAsync(long playerId)
+ {
+ var rows = await db.PlayerSettings
+ .Where(s => s.PlayerId == playerId && s.Key.StartsWith(ReadPrefix))
+ .Select(s => new { s.Key, s.Value })
+ .ToListAsync();
+
+ var map = new Dictionary();
+ foreach (var r in rows)
+ {
+ if (!long.TryParse(r.Key[ReadPrefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var roomId)) continue;
+ if (!long.TryParse(r.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var commentId)) continue;
+ if (!map.TryGetValue(roomId, out var cur) || commentId > cur) map[roomId] = commentId;
+ }
+ return map;
+ }
+
+ /// Read state only ever moves forward — the watch re-sends older
+ /// ids as the grid scrolls back up.
+ private async Task SetLastReadAsync(long playerId, long roomId, long commentId)
+ {
+ var key = $"{ReadPrefix}{roomId}";
+ var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
+ if (row is null)
+ {
+ db.PlayerSettings.Add(new PlayerSettingEntity
+ {
+ PlayerId = playerId,
+ Key = key,
+ Value = commentId.ToString(CultureInfo.InvariantCulture),
+ });
+ return;
+ }
+ if (long.TryParse(row.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var cur) && cur >= commentId) return;
+ row.Value = commentId.ToString(CultureInfo.InvariantCulture);
+ }
+
+ /// Room creator, or an accepted co-owner (Role 0) / moderator
+ /// (Role 1) — the same grant model
+ /// documents.
+ private async Task CanModerateRoomAsync(long roomId, long playerId)
+ {
+ if (await db.Rooms.AnyAsync(r => r.Id == roomId && r.CreatorPlayerId == playerId)) return true;
+ return await db.RoomRoles.AnyAsync(r =>
+ r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && (r.Role == 0 || r.Role == 1));
+ }
+
+ /// Splits roomcomment:{roomId}:{commentId}.
+ private static bool TrySplitCommentKey(string key, out long roomId, out long commentId)
+ {
+ roomId = 0;
+ commentId = 0;
+ if (!key.StartsWith(CommentPrefix, StringComparison.Ordinal)) return false;
+ var rest = key[CommentPrefix.Length..];
+ var sep = rest.IndexOf(':');
+ if (sep <= 0) return false;
+ return long.TryParse(rest[..sep], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)
+ && long.TryParse(rest[(sep + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out commentId);
+ }
+
+ private static float? ParseFloat(string? s) =>
+ float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
+
private async Task> ReadRoomIdsAsync()
{
var ids = new HashSet();
diff --git a/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs b/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs
index dc7702a..23343f6 100644
--- a/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs
+++ b/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using DorkNet.Server.Auth;
@@ -12,6 +13,19 @@ namespace DorkNet.Server.Controllers.API.Compatibility;
[ApiController]
public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBase
{
+ /// The 2023-03-21 client GETs this (verb register rdx=0
+ /// at RecNet.Runtime/FPIBGPIAOBI.txt:2984, immediately before the
+ /// "api/banappeal/generateCode" literal on :2980) and deserialises
+ /// the response as a bare String — the issuing method is
+ /// FGLDKEJLAKB<System.String> HBEPHMAKCDN() (:2759) and the
+ /// continuation is Func<IPCJLCNIBEG<String>,
+ /// FGLDKEJLAKB<String>> (:3019).
+ ///
+ /// It used to be POST-only returning {value: code}, so the client
+ /// got a 405 and, had it reached the handler, would have thrown parsing a
+ /// JSON object into a string. Ban-appeal code generation never worked.
+ /// The body is now the bare JSON string ("BA-123456").
+ [HttpGet("api/banappeal/generateCode")]
[HttpPost("api/banappeal/generateCode")]
[Authorize]
public async Task GenerateBanAppealCode()
@@ -25,7 +39,7 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
Value = code,
});
await db.SaveChangesAsync();
- return Ok(new { value = code });
+ return Content(JsonSerializer.Serialize(code), "application/json");
}
[HttpPost("api/CampusCard/v1/UpdateAndGetSubscription")]
@@ -96,6 +110,109 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
public IActionResult Ps5RecRoomPlusEnabledForAllPlayers()
=> Content("true", "application/json");
+ /// The 2023-03-21 client POSTs this — the request is built at
+ /// RecNet.Runtime/GAAPALMODOL.txt:104-106, where the route literal
+ /// "api/AppIntegrity/v1/iosproducts" goes into r9 and the
+ /// verb register rdx=2 = BestHTTP.HTTPMethods.Post (host
+ /// r8=1). The payload is a RAW JSON body — the serialized list is
+ /// handed to BNDIAONDFFF.FJLLPHFOOJJ (:138), not the
+ /// form-parameter helper — holding
+ /// List<GAAPALMODOL/KOLPHINEHDD>, i.e. a top-level array of
+ /// {Name:String, Price:Single, ProductId:String} (getters at
+ /// GAAPALMODOL_NestedType_KOLPHINEHDD.txt:3/25/45; formatter key table,
+ /// which also accepts camel/lower spellings, at KAONMCEONPF.txt:255-306).
+ /// The issuing method's return type is
+ /// FGLDKEJLAKB<PHMHCPEMABG> (:3) and PHMHCPEMABG is
+ /// {Boolean, String} (PHMHCPEMABG.txt:3/23) keyed
+ /// Success/Message (GBPDOLJBABB.txt:191-218).
+ ///
+ ///
+ /// registers the same
+ /// path GET-only, so the client's POST hit a 405 and the StoreKit
+ /// price report was dropped. Registering only the POST verb here leaves
+ /// that GET (and its consumers) untouched — the two never collide
+ /// because the method constraints are disjoint.
+ ///
+ /// The reported catalogue is what Apple's StoreKit resolved on the
+ /// device, so it is inherently per-account/per-storefront: it is
+ /// persisted as the reporting player's appintegrity:iosproducts
+ /// setting (same PlayerSettings convention as the ban-appeal handler
+ /// above), replacing any earlier report. Only a malformed body answers
+ /// Success=false; the client shows nothing on success and PHMHCPEMABG's
+ /// Message is left empty.
+ [HttpPost("api/AppIntegrity/v1/iosproducts")]
+ [Authorize]
+ public async Task IosProducts()
+ {
+ var pid = this.RequireCurrentPlayerId();
+
+ List? products;
+ try
+ {
+ products = await JsonSerializer.DeserializeAsync>(
+ Request.Body,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ }
+ catch (JsonException)
+ {
+ return IosProductsResult(false, "malformed_product_list");
+ }
+
+ if (products is null) return IosProductsResult(false, "malformed_product_list");
+
+ // "{productId}={price}" pairs rather than the raw JSON: the
+ // localized Name adds nothing a server-side reader needs and
+ // PlayerSettingEntity.Value caps at 1024 chars, so entries are
+ // dropped (never truncated mid-pair) once the budget is spent.
+ var parts = new List();
+ var length = 0;
+ foreach (var p in products)
+ {
+ var productId = p.ProductId?.Trim();
+ if (string.IsNullOrEmpty(productId)) continue;
+ var pair = $"{productId}={p.Price.ToString(CultureInfo.InvariantCulture)}";
+ var cost = parts.Count == 0 ? pair.Length : pair.Length + 1;
+ if (length + cost > 1024) break;
+ parts.Add(pair);
+ length += cost;
+ }
+
+ const string key = "appintegrity:iosproducts";
+ var row = await db.PlayerSettings
+ .FirstOrDefaultAsync(s => s.PlayerId == pid && s.Key == key);
+ if (row is null)
+ {
+ db.PlayerSettings.Add(new PlayerSettingEntity
+ {
+ PlayerId = pid,
+ Key = key,
+ Value = string.Join(';', parts),
+ });
+ }
+ else
+ {
+ row.Value = string.Join(';', parts);
+ }
+ await db.SaveChangesAsync();
+
+ return IosProductsResult(true, string.Empty);
+ }
+
+ /// PHMHCPEMABG accepts PascalCase, camelCase and all-lowercase
+ /// key spellings (GBPDOLJBABB.txt:191-218), so either casing alone would
+ /// satisfy it; both are emitted for the same reason the CampusCard
+ /// payload above does. Dictionary keys go out verbatim — the global
+ /// serializer sets PropertyNamingPolicy = null
+ /// (Startup/ServiceCollectionExtensions.cs:381).
+ private IActionResult IosProductsResult(bool success, string message)
+ => Ok(new Dictionary
+ {
+ ["Success"] = success,
+ ["Message"] = message,
+ ["success"] = success,
+ ["message"] = message,
+ });
+
[HttpPost("api/clubreporting/v1/report")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
@@ -110,7 +227,7 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
// Prefix with "[club {id}]" so admins can tell club reports apart
// from player reports in the shared ReportEntity queue.
- var message = $"[club {club.Id}] {req.Message ?? string.Empty}".Trim();
+ var message = $"[club {club.Id}] {req.Details ?? req.Message ?? string.Empty}".Trim();
if (message.Length > 1000) message = message[..1000];
db.Reports.Add(new ReportEntity
{
@@ -155,7 +272,13 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
?? form["ClubId"].FirstOrDefault(), out var clubId) ? clubId : 0,
ReportCategory = int.TryParse(form["reportCategory"].FirstOrDefault()
?? form["ReportCategory"].FirstOrDefault(), out var cat) ? cat : 5,
- Message = form["message"].FirstOrDefault() ?? form["Message"].FirstOrDefault(),
+ // The 2023 client names the free-text field "details"
+ // (IKMMOCKDKAF.txt:25303-25322), not "message" — reading only
+ // the latter stored every club report with an empty body.
+ Message = form["details"].FirstOrDefault()
+ ?? form["Details"].FirstOrDefault()
+ ?? form["message"].FirstOrDefault()
+ ?? form["Message"].FirstOrDefault(),
};
}
try
@@ -173,10 +296,24 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
public long PlatformAccountSubscribedPlayerId { get; set; }
}
+ /// One entry of the client's raw JSON array —
+ /// GAAPALMODOL/KOLPHINEHDD, whose three properties are String, Single,
+ /// String in that field order (offsets 0x10/0x18/0x20 in
+ /// GAAPALMODOL_NestedType_KOLPHINEHDD.txt) and whose formatter writes
+ /// the keys Name / Price / ProductId (KAONMCEONPF.txt:321-360).
+ private sealed class IosProduct
+ {
+ public string? Name { get; set; }
+ public float Price { get; set; }
+ public string? ProductId { get; set; }
+ }
+
private sealed class ClubReportRequest
{
public long ClubId { get; set; }
public int ReportCategory { get; set; } = 5;
public string? Message { get; set; }
+ /// The client's spelling of .
+ public string? Details { get; set; }
}
}
diff --git a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs
index 47e359d..2d2c93d 100644
--- a/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs
+++ b/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs
@@ -17,8 +17,18 @@ namespace DorkNet.Server.Controllers.API.CustomAvatarItems;
public class CustomAvatarItemsController(
DorkNetDbContext db,
LevelService level,
- DomainConfig domain) : ControllerBase
+ DomainConfig domain,
+ IObjectStorage storage,
+ ILogger logger) : ControllerBase
{
+ /// Floor price for a publicly listed custom item. The client
+ /// asks for this over the wire (api/customAvatarItems/v1/minPriceForPublicItem,
+ /// RecNet.Runtime/MPBLNLMCEDL.txt:6893) and then enforces it locally in the
+ /// designer, so the number the create/update paths clamp to and the number
+ /// served on that route have to be the same constant or the client's own
+ /// validation disagrees with the server's rejection.
+ private const int MinPublicItemPrice = 100;
+
[HttpGet]
[AllowAnonymous]
public async Task List(
@@ -47,6 +57,71 @@ public class CustomAvatarItemsController(
return Ok(result.Select(ToWire));
}
+ /// POST api/customAvatarItems/v1 — publish a shirt from the
+ /// in-game designer. The collection route was GET-only, so every
+ /// create 405'd and the designer's "publish" button could never work.
+ ///
+ /// The 2023-03-21 client sends multipart/form-data (verb ordinal
+ /// rdx=2=POST at RecNet.Runtime/MPBLNLMCEDL.txt:1352-1354): a named
+ /// param "metadata" carrying JsonConvert.SerializeObject of the
+ /// DCKDCBDHDKI request object, then TWO file parts, "thumbnailImage"
+ /// and "design", both with filename "file.bin"
+ /// (:1362-1390). DCKDCBDHDKI's field order is pinned by the arg-store
+ /// sequence at :1096-1133 — +0x10 Name, +0x18 Description, +0x20 Price,
+ /// +0x24 BaseAvatarItemId(int?), +0x30 Color ("#RRGGBB", built via
+ /// ColorUtility.ToHtmlStringRGB at :1118-1131), +0x38 the permission
+ /// enum we store as ItemType. The metadata JSON KEY names live in
+ /// attribute metadata the dump doesn't render, so every field is read
+ /// through an alias list rather than a single guessed key.
+ [HttpPost]
+ [Authorize]
+ [Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")]
+ public async Task Create()
+ {
+ var pid = this.RequireCurrentPlayerId();
+ var req = await ReadDesignRequestAsync();
+
+ var name = (req.Name ?? string.Empty).Trim();
+ if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
+ if (name.Length > 128) name = name[..128];
+ var description = (req.Description ?? string.Empty).Trim();
+ if (description.Length > 1024) description = description[..1024];
+
+ var item = new CustomAvatarItemEntity
+ {
+ CreatorPlayerId = pid,
+ Name = name,
+ Description = description,
+ Price = Math.Max(MinPublicItemPrice, req.Price ?? MinPublicItemPrice),
+ ItemType = req.ItemType ?? 0,
+ BaseAvatarItemId = req.BaseAvatarItemId ?? 0,
+ Color = Trim(req.Color, 64),
+ // No boolean lives in DCKDCBDHDKI, so a freshly created item is
+ // private until the permission enum (ItemType) says otherwise.
+ IsPublic = req.IsPublic ?? false,
+ };
+
+ // "thumbnailImage" → ImageName (card art), "design" → AssetName (the
+ // shirt texture itself). Fall back to positional order for any client
+ // that names the parts differently.
+ var (thumbnail, asset) = ReadItemFileParts();
+ if (thumbnail is not null)
+ item.ImageName = await StoreImageBlobAsync(pid, thumbnail, "custom item thumbnail");
+ if (asset is not null)
+ item.AssetName = await StoreImageBlobAsync(pid, asset, "custom item design");
+ if (item.ImageName.Length == 0) item.ImageName = Trim(req.ImageName, 256);
+ if (item.AssetName.Length == 0) item.AssetName = Trim(req.AssetName, 256);
+
+ db.CustomAvatarItems.Add(item);
+ await db.SaveChangesAsync();
+ await EnsureOwnershipAsync(pid, item.Id);
+
+ logger.LogInformation(
+ "[customAvatarItems] created {PublicId} by {Pid} name='{Name}' image={Image} asset={Asset}",
+ item.PublicId, pid, item.Name, item.ImageName, item.AssetName);
+ return Ok(ToWire(item));
+ }
+
[HttpGet("{id:guid}")]
[AllowAnonymous]
public async Task Get(Guid id)
@@ -57,12 +132,101 @@ public class CustomAvatarItemsController(
return Ok(ToWire(item));
}
+ /// PUT api/customAvatarItems/v1/{id} — edit an existing item's
+ /// metadata. Only GET was registered on the id route, so the details
+ /// screen's Save button 405'd ("Unable to update custom avatar item").
+ ///
+ /// The client builds the URL with String.Format("{0}/v1/{1}") over
+ /// "api/customAvatarItems" and issues verb ordinal 3 = PUT
+ /// (RecNet.Runtime/MPBLNLMCEDL.txt:1899-1913). The body is RAW JSON —
+ /// JsonConvert.SerializeObject of MOBHOFPIIIJ handed to
+ /// BNDIAONDFFF.FJLLPHFOOJJ at :1890-1920 — NOT form-urlencoded, so this
+ /// reads the body itself rather than model-binding it. MOBHOFPIIIJ
+ /// carries exactly four properties whose accessors are shared with
+ /// NOAAFNFJPFB (MOBHOFPIIIJ.txt:3-77): Name, Description, Price(int?)
+ /// and the permission enum stored here as ItemType. Every field is
+ /// optional — absent members leave the stored value alone.
+ [HttpPut("{id:guid}")]
+ [Authorize]
+ public async Task Update(Guid id)
+ {
+ var pid = this.RequireCurrentPlayerId();
+ var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
+ if (item is null) return NotFound();
+ if (item.CreatorPlayerId != pid) return Forbid();
+
+ var req = await ReadDesignRequestAsync();
+ if (req.Name is { } rawName)
+ {
+ var name = rawName.Trim();
+ if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
+ item.Name = name.Length > 128 ? name[..128] : name;
+ }
+ if (req.Description is { } rawDescription)
+ {
+ var description = rawDescription.Trim();
+ item.Description = description.Length > 1024 ? description[..1024] : description;
+ }
+ if (req.Price is { } price) item.Price = Math.Max(MinPublicItemPrice, price);
+ if (req.ItemType is { } itemType) item.ItemType = itemType;
+ if (req.BaseAvatarItemId is { } baseItemId) item.BaseAvatarItemId = baseItemId;
+ if (req.Color is { } color) item.Color = Trim(color, 64);
+ if (req.ImageName is { } imageName) item.ImageName = Trim(imageName, 256);
+ if (req.AssetName is { } assetName) item.AssetName = Trim(assetName, 256);
+ if (req.IsPublic is { } isPublic) item.IsPublic = isPublic;
+ item.UpdatedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync();
+ return Ok(ToWire(item));
+ }
+
+ /// DELETE api/customAvatarItems/v1/{id} — verb ordinal 4 on the
+ /// same String.Format("{0}/v1/{1}") URL
+ /// (RecNet.Runtime/MPBLNLMCEDL.txt:2188-2199); the client is
+ /// fire-and-forget, so the missing route made deleted shirts silently
+ /// reappear on the next list refresh.
+ ///
+ /// has no IsDeleted column (unlike
+ /// UgcPurchasableEntity), and adding one is a schema migration, so this
+ /// is a real delete: the ownership rows go first so nobody keeps the
+ /// item in their wardrobe, then the item row itself.
+ [HttpDelete("{id:guid}")]
+ [Authorize]
+ public async Task Delete(Guid id)
+ {
+ var pid = this.RequireCurrentPlayerId();
+ var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
+ if (item is null) return Ok();
+ if (item.CreatorPlayerId != pid) return Forbid();
+
+ var ownership = await db.CustomAvatarItemOwnership
+ .Where(o => o.CustomAvatarItemId == item.Id)
+ .ToListAsync();
+ if (ownership.Count > 0) db.CustomAvatarItemOwnership.RemoveRange(ownership);
+ db.CustomAvatarItems.Remove(item);
+ await db.SaveChangesAsync();
+ logger.LogInformation(
+ "[customAvatarItems] deleted {PublicId} by {Pid} ({Owners} ownership rows removed)",
+ id, pid, ownership.Count);
+ return Ok();
+ }
+
+ /// GET/POST api/customAvatarItems/v1/bulk — resolve the custom
+ /// items other players are wearing. The guid-list parameter key the
+ /// client uses is the literal "customAvatarItemIds"
+ /// (RecNet.Runtime/MPBLNLMCEDL_NestedType_KKEBBAAGHAM.txt:232-244, POST
+ /// ordinal rdx=2), which no read path here honoured, and the old
+ /// [FromBody] BulkRequest parameter made ASP.NET reject a
+ /// form-urlencoded body with 415 before the handler ran. Result: an
+ /// empty array, remote players' shirts never rendered, and the client
+ /// re-queried the same ids forever — the same failure the ugcPurchasables
+ /// bulk endpoint hit. The binding is gone;
+ /// now reads the key from the JSON body, the form and the query.
[HttpGet("bulk")]
[HttpPost("bulk")]
[AllowAnonymous]
- public async Task Bulk([FromBody] BulkRequest? body)
+ public async Task Bulk()
{
- var ids = await ReadIdsAsync(body);
+ var ids = await ReadIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty