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}\s+ADD\s+COLUMN\s+{ifNotExists}{Q}(?\w+){Q}", + RegexOptions.IgnoreCase) + .Select(m => $"{m.Groups["table"].Value}.{m.Groups["column"].Value}") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var tables = Regex + .Matches(sql, + $@"CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\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()); var rows = await db.CustomAvatarItems .Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId())) @@ -121,14 +285,18 @@ public class CustomAvatarItemsController( skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 100); var currentPlayerId = this.CurrentPlayerId(); - var rows = await db.CustomAvatarItems + var query = db.CustomAvatarItems .Where(i => i.CreatorPlayerId == creatorId - && (i.IsPublic || i.CreatorPlayerId == currentPlayerId)) + && (i.IsPublic || i.CreatorPlayerId == currentPlayerId)); + var total = await query.CountAsync(); + var rows = await query .OrderByDescending(i => i.UpdatedAt) .Skip(skip) .Take(take) .ToListAsync(); - return Ok(rows.Select(ToWire)); + // Paged container, not a bare array — the client deserializes this into + // CKFLFJCNEPH {Results, TotalResults}, same as Owned() below. + return Ok(new { Results = rows.Select(ToWire), TotalResults = total }); } [HttpGet("me")] @@ -203,7 +371,11 @@ public class CustomAvatarItemsController( var allowed = player is not null && !player.IsJunior && (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow); - return Ok(new { IsCreationAllowed = allowed, Allowed = allowed }); + // Bare JSON boolean, like the two sibling gates below: the client's + // issuing method is FGLDKEJLAKB GGMDLMLJDDK() + // (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route literal on :6205), so an + // object body threw and the can-create gate blocked shirt creation. + return Content(allowed ? "true" : "false", "application/json"); } [HttpGet("isCreationEnabled")] @@ -214,9 +386,126 @@ public class CustomAvatarItemsController( [AllowAnonymous] public IActionResult IsRenderingEnabled() => Content("true", "application/json"); + /// The client DTO (IHLMDLKOLNB) is an object with exactly one + /// Int32 property, but its accessor name (GFGCCNBDKAK) appears nowhere + /// else in the dump, so — unlike Price/BaseAvatarItemId/Color, which are + /// pinned by accessors shared with NOAAFNFJPFB — the JSON key is NOT + /// recoverable. All four spellings below are UNPROVEN hedges; Newtonsoft + /// ignores the ones that don't match, and a total miss falls back to the + /// DTO's static default (IHLMDLKOLNB.txt:75-117) rather than throwing. [HttpGet("minPriceForPublicItem")] [AllowAnonymous] - public IActionResult MinPriceForPublicItem() => Ok(new { MinPrice = 100, Price = 100 }); + public IActionResult MinPriceForPublicItem() => Ok(new + { + MinPrice = MinPublicItemPrice, + Price = MinPublicItemPrice, + MinimumPrice = MinPublicItemPrice, + MinPriceForPublicItem = MinPublicItemPrice, + }); + + /// GET api/customAvatarItems/v1/design — hand back the caller's + /// saved in-progress shirt design. Verb ordinal 0 = GET + /// (RecNet.Runtime/MPBLNLMCEDL.txt:4782-4783); only POST was registered, + /// so the designer logged "Unable to get custom avatar design info" every + /// time it opened. + /// + /// The response DTO NCMMAGDIBND is {Int32 @0x10, Int32? @0x14, String + /// @0x20, String @0x28} (NCMMAGDIBND.txt:3-145). The obfuscator's name + /// map is per-property-name rather than per-member, so those four + /// accessors identify the properties: IDELLAGIFAE/MOKDHHGDMJB is the same + /// pair used for BaseAvatarItemId on GIOBJIGEMNG and DCKDCBDHDKI, + /// GJEFKPNEFGI is the Color set from ColorUtility.ToHtmlStringRGB, and + /// DPJMMBLGKGF / BADCCGPNLMN are the leading int and the first string of + /// NOAAFNFJPFB — the creator id and the image name. Hence + /// {CreatorPlayerId, BaseAvatarItemId, ImageName, Color}. The extra keys + /// are inert aliases. + /// + /// Returns an empty design rather than 404 when nothing is saved so the + /// designer opens clean instead of showing the error toast. + [HttpGet("design")] + [Authorize] + public async Task GetDesign() + { + var pid = this.RequireCurrentPlayerId(); + var design = await LoadDesignAsync(pid) ?? new StoredDesign(); + return Ok(new + { + CreatorPlayerId = (int)pid, + CreatorId = (int)pid, + design.BaseAvatarItemId, + design.ImageName, + ImageUrl = string.IsNullOrWhiteSpace(design.ImageName) + ? string.Empty + : $"https://{domain.Sub("cdn")}/{design.ImageName}", + design.Color, + }); + } + + /// PUT api/customAvatarItems/v1/design — save the in-progress + /// shirt texture. The route only accepted POST, so the designer could + /// never persist a design ("Unable to save custom avatar design"). + /// + /// Verb ordinal 3 = PUT with a multipart body: named param + /// "metadata" plus a file part "design" with filename + /// "file.bin" + /// (RecNet.Runtime/MPBLNLMCEDL_NestedType_OPEGIAOIHMA.txt:200-227). The + /// metadata object GIOBJIGEMNG holds just two properties — a + /// Nullable<Int32> base avatar item id at +0x10 and the "#RRGGBB" + /// colour string at +0x18 (GIOBJIGEMNG.txt:3-43), written from the + /// FKCJDHHJAFF(texture, color, baseItemId) args at MPBLNLMCEDL.txt:4652-4654. + /// The client's return type is status-only (LDGADANDBIO), so a bare 200 + /// is the whole contract. + [HttpPut("design")] + [Authorize] + [Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")] + public async Task SaveDesign() + { + var pid = this.RequireCurrentPlayerId(); + var req = await ReadDesignRequestAsync(); + var existing = await LoadDesignAsync(pid); + + var design = new StoredDesign + { + BaseAvatarItemId = req.BaseAvatarItemId ?? existing?.BaseAvatarItemId, + Color = Trim(req.Color, 64) is { Length: > 0 } color ? color : existing?.Color ?? string.Empty, + ImageName = existing?.ImageName ?? string.Empty, + }; + + // The texture arrives as the "design" part; accept any single file + // part as a fallback for a client that names it differently. + var file = Request.HasFormContentType + ? Request.Form.Files.GetFile("design") ?? Request.Form.Files.FirstOrDefault() + : null; + if (file is not null && file.Length > 0) + { + var bytes = await ReadFileBytesAsync(file); + var blobName = DesignImageBlobName(pid); + await PutBlobAsync(blobName, bytes, SniffImageContentType(bytes), "custom avatar design"); + design.ImageName = blobName; + } + + await SaveDesignAsync(pid, design); + return Ok(); + } + + /// DELETE api/customAvatarItems/v1/design — discard the saved + /// design. Verb ordinal 4 on the same literal + /// (RecNet.Runtime/MPBLNLMCEDL.txt:4862-4864); without it the client + /// logged "Unable to delete custom avatar item design". + /// + /// has no delete operation, so the record is + /// tombstoned (a Deleted marker that reads + /// as "no design") and the texture object is truncated to zero bytes so + /// the pixels really are gone. + [HttpDelete("design")] + [Authorize] + public async Task DeleteDesign() + { + var pid = this.RequireCurrentPlayerId(); + await SaveDesignAsync(pid, new StoredDesign { Deleted = true }); + await PutBlobAsync(DesignImageBlobName(pid), Array.Empty(), "image/png", "custom avatar design"); + return Ok(); + } [HttpPost("design")] [Authorize] @@ -248,7 +537,7 @@ public class CustomAvatarItemsController( item.Name = name; item.Description = description; - item.Price = Math.Max(100, req.Price ?? item.Price); + item.Price = Math.Max(MinPublicItemPrice, req.Price ?? item.Price); item.ItemType = req.ItemType ?? item.ItemType; item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId; item.Color = Trim(req.Color, 64); @@ -262,6 +551,13 @@ public class CustomAvatarItemsController( return Ok(ToWire(item)); } + /// The 2023-03-21 client buys custom items through the + /// econ/ host prefix — econ/customAvatarItems/v1/{id}/purchase + /// — which was never registered, so every custom-shirt purchase 404'd. The + /// rooted route below is an alias onto the same handler. requestedPrice + /// arrives as a query param and is validated against the item's real price + /// so a tampered client can't buy at its own number. + [HttpPost("/econ/customAvatarItems/v1/{id:guid}/purchase")] [HttpPost("{id:guid}/purchase")] [Authorize] public async Task Purchase(Guid id) @@ -272,6 +568,16 @@ public class CustomAvatarItemsController( if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id)) return Ok(new { Success = true, AlreadyOwned = true, Item = ToWire(item) }); + // requestedPrice is baked into the URL the client formats — + // "{0}/v1/{1}/purchase/?requestedPrice={2}" + // (RecNet.Runtime/MPBLNLMCEDL.txt:5084). It is the price the player was + // SHOWN, so a value below the real price means the listing moved under + // them (or the request was tampered with); reject instead of silently + // charging more than the confirmation dialog said. + var requestedPrice = int.TryParse(Request.Query["requestedPrice"].FirstOrDefault(), out var rp) ? rp : (int?)null; + if (requestedPrice is { } shown && shown < item.Price) + return Ok(new { Success = false, Error = "price_changed", Price = item.Price, Item = ToWire(item) }); + var balance = await level.GetBalanceAsync(pid, 2); if (balance < item.Price) return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance }); @@ -284,6 +590,236 @@ public class CustomAvatarItemsController( return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) }); } + /// POST api/customAvatarItems/v1/{id}/report — file a moderation + /// report against another player's custom shirt. No report route was + /// registered anywhere on the server, so the report dialog's "Submit" + /// button 404'd and the client toasted "Unable to report custom avatar + /// item {0}". + /// + /// Verb ordinal dl=2 = POST, on the URL + /// String.Format("{0}/v1/{1}/report") over "api/customAvatarItems" + /// (RecNet.Runtime/MPBLNLMCEDL.txt:4261-4275; the failure literal is + /// :4301). The body is RAW JSON — JsonConvert.SerializeObject of a + /// JEHCPEEKMML handed straight to BNDIAONDFFF.FJLLPHFOOJJ (:4252-4256, + /// :4282), NOT form-urlencoded — so the body is read here instead of + /// model-bound, exactly like PUT {id} above. + /// + /// The issuing method IALGIAGBJMA (:4079) returns LDGADANDBIO — status + /// only. Its continuation chain (0x181F2F300 → 0x1836E6740 → + /// BFDGEKKNAPB.PEFKBAEAOPD, :4285-4294) is byte-for-byte the one the + /// DELETE {id} path uses (:2206-2215), and that path is satisfied by a + /// bare 200, so an empty 200 is the whole response contract here too. + /// + /// JEHCPEEKMML has exactly three members, pinned by field offset from the + /// accessor bodies (JEHCPEEKMML.txt:6/86/108) and by the arg-store + /// sequence that fills it (:4239-4247): +0x10 the reason enum, +0x18 the + /// free-text detail, +0x20 a Nullable<Int32>. Their JSON KEY names + /// live in attribute metadata Cpp2IL doesn't render — obfuscated in the + /// 2023.06 dump too (EFPKFBKGLCD, dump.cs:1256458-1256469) — so each is + /// matched against an alias list, with a positional fallback in + /// declaration order for anything unrecognised (Newtonsoft writes members + /// in declaration order and the DTO has exactly these three). + /// + /// The reason enum IS readable: LGEPGEHKNAG.KFCMACCLDEJ in the 2023.06 + /// dump (dump.cs:1250039-1250044) — CoC_Discriminatory=0, CoC_Sexual=1, + /// CoC_Trolling=2, Misleading=3, Other=4, InappropriateClothing=5. That is + /// a DIFFERENT numbering from 's + /// RoomieReportCategory convention (Harassment=1 … Other=5), so the + /// ordinal is stored verbatim and the readable name is written into the + /// message — otherwise a moderator reading the shared queue cannot tell a + /// CoC_Sexual=1 clothing report from a Harassment=1 player report. + /// + /// has no custom-avatar-item column and adding + /// one is a migration, so the reported item's guid is carried in the + /// stored text — the same thing the sibling screen-share report handler + /// does with its image/instance evidence. That marker prefix is also what + /// makes the duplicate check below work. + [HttpPost("{id:guid}/report")] + [Authorize] + public async Task Report(Guid id) + { + var pid = this.RequireCurrentPlayerId(); + var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id); + if (item is null) return NotFound(); + // The report button only renders on someone else's item, so this is an + // impossible UI state; reject rather than pollute the queue. + if (item.CreatorPlayerId == pid) return BadRequest(new { error = "cannot_report_own_item" }); + + var req = await ReadReportRequestAsync(); + // The dialog refuses to submit without a category ("Cannot report + // custom clothing without a report category." — + // Assembly-CSharp/RRUI/Data/CustomAvatarItemReportModel.txt:1941), so + // a missing reason means a non-stock caller; bucket it as Other=4. + var reason = req.Reason ?? 4; + + var context = new List { $"reason={ReportReasonName(reason)}({reason})" }; + // The only in-game caller passes null for the Nullable + // (CustomAvatarItemReportModel.txt:2008/2011 push rbx=0 for both it and + // the trailing bool), so its meaning is unproven — keep whatever a + // client does send rather than silently dropping evidence. + if (req.Context is { } ctx) context.Add($"ctx={ctx}"); + + var details = (req.Details ?? string.Empty).Trim(); + var message = $"{ReportMarker(id)}{string.Join(' ', context)}] {details}".Trim(); + if (message.Length > 1000) message = message[..1000]; + + // One open report per (reporter, item): the dialog is re-openable and + // the client retries on a failed submit, so without this a single + // annoyed player can flood the moderation queue. + var marker = ReportMarker(id); + var duplicate = await db.Reports.AnyAsync(r => + r.ReporterPlayerId == pid && r.ResolvedAt == null && r.Message.StartsWith(marker)); + if (duplicate) + { + logger.LogInformation( + "[customAvatarItems] duplicate report of {PublicId} by {Pid} ignored", id, pid); + return Ok(); + } + + db.Reports.Add(new ReportEntity + { + ReporterPlayerId = pid, + TargetPlayerId = item.CreatorPlayerId, + Category = reason, + Message = message, + }); + await db.SaveChangesAsync(); + logger.LogInformation( + "[customAvatarItems] reported {PublicId} (creator {Creator}) by {Pid} reason={Reason}", + id, item.CreatorPlayerId, pid, ReportReasonName(reason)); + return Ok(); + } + + /// Prefix every stored custom-item report with the item guid so + /// the row is attributable without a dedicated column, and so the + /// duplicate check can find prior reports with a plain LIKE. + private static string ReportMarker(Guid id) => $"[customAvatarItem {id:D} "; + + /// LGEPGEHKNAG.KFCMACCLDEJ, verbatim from the 2023.06 dump + /// (dump.cs:1250039-1250044). Index IS the wire ordinal. + private static readonly string[] ReportReasonNames = + { + "CoC_Discriminatory", "CoC_Sexual", "CoC_Trolling", + "Misleading", "Other", "InappropriateClothing", + }; + + private static string ReportReasonName(int reason) => + reason >= 0 && reason < ReportReasonNames.Length ? ReportReasonNames[reason] : "Unknown"; + + /// Read the report body. The stock client sends raw JSON, but + /// form encodings are accepted too so an admin/tooling caller can post the + /// same three fields flat. + private async Task ReadReportRequestAsync() + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + string? Field(params string[] keys) + { + foreach (var key in keys) + if (form[key].FirstOrDefault() is { } v) return v; + return null; + } + + return new ReportRequest + { + Reason = ParseReportReason(Field("reason", "Reason", "reportReason", "ReportReason", + "category", "Category", "reportCategory", "ReportCategory", "type", "Type")), + Details = Field("details", "Details", "description", "Description", + "message", "Message", "comment", "Comment"), + Context = int.TryParse( + Field("contextId", "ContextId", "instanceId", "InstanceId", + "roomInstanceId", "RoomInstanceId", "roomId", "RoomId", + "accountId", "AccountId", "playerId", "PlayerId"), + out var ctx) + ? ctx + : null, + }; + } + + using var reader = new StreamReader(Request.Body); + return ParseReportJson(await reader.ReadToEndAsync()); + } + + /// Lenient JSON → . Alias-matched + /// first; anything left over is assigned positionally in JEHCPEEKMML's + /// declaration order (reason, detail, nullable int) so an unguessed key + /// spelling still lands in the right slot. + private static ReportRequest ParseReportJson(string json) + { + var req = new ReportRequest(); + if (string.IsNullOrWhiteSpace(json)) return req; + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return req; + + var unmatched = new List(); + foreach (var prop in doc.RootElement.EnumerateObject()) + { + switch (prop.Name.ToLowerInvariant()) + { + case "reason" or "reportreason" or "category" or "reportcategory" or "type" or "reporttype": + req.Reason ??= ParseReportReason(prop.Value); + break; + case "details" or "detail" or "description" or "reportdescription" + or "message" or "comment" or "text": + req.Details ??= AsString(prop.Value); + break; + case "contextid" or "instanceid" or "roominstanceid" or "roomid" + or "accountid" or "playerid" or "reportedplayerid": + req.Context ??= AsInt(prop.Value); + break; + default: + unmatched.Add(prop.Value); + break; + } + } + + foreach (var value in unmatched) + { + if (req.Reason is null && ParseReportReason(value) is { } reason) + { + req.Reason = reason; + continue; + } + + if (req.Details is null && value.ValueKind == JsonValueKind.String) + { + req.Details = value.GetString(); + continue; + } + + req.Context ??= AsInt(value); + } + } + catch (JsonException) + { + } + + return req; + } + + /// Accept the reason as an ordinal or as one of the enum's own + /// names — Newtonsoft writes an int by default, but a StringEnumConverter + /// on the DTO (an attribute the dump doesn't render) would write the name. + private static int? ParseReportReason(JsonElement value) => value.ValueKind switch + { + JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null, + JsonValueKind.String => ParseReportReason(value.GetString()), + _ => null, + }; + + private static int? ParseReportReason(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + if (int.TryParse(value, out var ordinal)) return ordinal; + var index = Array.FindIndex( + ReportReasonNames, + n => string.Equals(n, value, StringComparison.OrdinalIgnoreCase)); + return index >= 0 ? index : null; + } + private async Task EnsureOwnershipAsync(long playerId, long itemId) { if (await db.CustomAvatarItemOwnership.AnyAsync(o => @@ -300,64 +836,311 @@ public class CustomAvatarItemsController( await db.SaveChangesAsync(); } - private async Task> ReadIdsAsync(BulkRequest? body) + /// Collect the bulk request's guid list. The 2023 client's key is + /// "customAvatarItemIds" (MPBLNLMCEDL_NestedType_KKEBBAAGHAM.txt:242); + /// older callers use ids/itemIds. Which transport the request builder picks + /// for a POST collection param isn't decidable from ISIL, so all three are + /// read: raw JSON body, form fields and query pairs. The body is read + /// manually — a [FromBody] parameter would 415 a form-encoded POST before + /// the handler ran. + private async Task> ReadIdsAsync() { var result = new List(); - if (body?.Ids is { Count: > 0 }) - result.AddRange(body.Ids); - if (body?.ItemIds is { Count: > 0 }) - result.AddRange(body.ItemIds); + void AddCsv(string? value) + { + foreach (var part in (value ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (Guid.TryParse(part, out var id)) result.Add(id); + } + + if ((Request.ContentLength ?? 0) > 0 + && Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + { + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + var root = doc.RootElement; + // Either a bare guid array or an object carrying one under any + // of the accepted key spellings. + if (root.ValueKind == JsonValueKind.Array) + { + AddJsonGuids(root, result); + } + else if (root.ValueKind == JsonValueKind.Object) + { + foreach (var prop in root.EnumerateObject()) + { + if (!IdListKeys.Contains(prop.Name)) continue; + if (prop.Value.ValueKind == JsonValueKind.Array) AddJsonGuids(prop.Value, result); + else if (prop.Value.ValueKind == JsonValueKind.String) AddCsv(prop.Value.GetString()); + } + } + } + catch (JsonException) + { + } + } foreach (var pair in Request.Query) foreach (var value in pair.Value) - foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - if (Guid.TryParse(part, out var id)) result.Add(id); + AddCsv(value); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); - foreach (var key in new[] { "ids", "Ids", "itemIds", "ItemIds" }) - foreach (var value in form[key]) - foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - if (Guid.TryParse(part, out var id)) result.Add(id); + foreach (var field in form) + { + if (!IdListKeys.Contains(field.Key)) continue; + foreach (var value in field.Value) AddCsv(value); + } } return result.Distinct().Take(200).ToList(); } + private static void AddJsonGuids(JsonElement array, List into) + { + foreach (var el in array.EnumerateArray()) + if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var id)) + into.Add(id); + } + + /// Accepted spellings of the bulk guid-list key. Case-insensitive + /// set so "customAvatarItemIds" / "CustomAvatarItemIds" both land. + private static readonly HashSet IdListKeys = new(StringComparer.OrdinalIgnoreCase) + { + "customAvatarItemIds", "ids", "itemIds", + }; + + /// Read the fields of a create / update / design-save request. + /// Three encodings have to work: the designer's multipart bodies wrap + /// everything in a single "metadata" form field holding JSON + /// (MPBLNLMCEDL.txt:1362 and MPBLNLMCEDL_NestedType_OPEGIAOIHMA.txt:210), + /// PUT {id} sends a raw JSON body, and older/admin callers post flat form + /// fields. The JSON key names are not in the dump — they live in attribute + /// metadata Cpp2IL doesn't render — so each field is matched against an + /// alias list rather than one guessed spelling. Only the field ORDER is + /// proven (from the arg-store sequence); the names are inferred. private async Task ReadDesignRequestAsync() { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); + var metadata = form["metadata"].FirstOrDefault() ?? form["Metadata"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(metadata)) + return ParseDesignJson(metadata); + + string? Field(params string[] keys) + { + foreach (var key in keys) + if (form[key].FirstOrDefault() is { } v) return v; + return null; + } + return new DesignRequest { - Id = Guid.TryParse(form["id"].FirstOrDefault() ?? form["Id"].FirstOrDefault(), out var id) ? id : null, - Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(), - Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(), - Price = int.TryParse(form["price"].FirstOrDefault() ?? form["Price"].FirstOrDefault(), out var price) ? price : null, - ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var itemType) ? itemType : null, - BaseAvatarItemId = int.TryParse(form["baseAvatarItemId"].FirstOrDefault() ?? form["BaseAvatarItemId"].FirstOrDefault(), out var baseId) ? baseId : null, - Color = form["color"].FirstOrDefault() ?? form["Color"].FirstOrDefault(), - ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(), - AssetName = form["assetName"].FirstOrDefault() ?? form["AssetName"].FirstOrDefault(), - IsPublic = bool.TryParse(form["isPublic"].FirstOrDefault() ?? form["IsPublic"].FirstOrDefault(), out var pub) ? pub : null, + Id = Guid.TryParse(Field("id", "Id"), out var id) ? id : null, + Name = Field("name", "Name"), + Description = Field("description", "Description"), + Price = int.TryParse(Field("price", "Price"), out var price) ? price : null, + ItemType = int.TryParse(Field("itemType", "ItemType", "permission", "Permission"), out var itemType) ? itemType : null, + BaseAvatarItemId = int.TryParse(Field("baseAvatarItemId", "BaseAvatarItemId"), out var baseId) ? baseId : null, + Color = Field("color", "Color"), + ImageName = Field("imageName", "ImageName"), + AssetName = Field("assetName", "AssetName"), + IsPublic = bool.TryParse(Field("isPublic", "IsPublic"), out var pub) ? pub : null, }; } + using var reader = new StreamReader(Request.Body); + return ParseDesignJson(await reader.ReadToEndAsync()); + } + + /// Lenient JSON → . Members that are + /// absent stay null so callers can tell "not sent" from "sent empty" — + /// PUT {id} relies on that to leave untouched fields alone. + private static DesignRequest ParseDesignJson(string json) + { + var req = new DesignRequest(); + if (string.IsNullOrWhiteSpace(json)) return req; try { - return await JsonSerializer.DeserializeAsync( - Request.Body, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) - ?? new DesignRequest(); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return req; + foreach (var prop in doc.RootElement.EnumerateObject()) + { + var value = prop.Value; + switch (prop.Name.ToLowerInvariant()) + { + case "id" or "customavataritemid": + if (value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out var id)) req.Id = id; + break; + case "name" or "itemname" or "title": + req.Name = AsString(value); + break; + case "description" or "desc": + req.Description = AsString(value); + break; + case "price": + req.Price = AsInt(value); + break; + // The enum at DCKDCBDHDKI+0x38 / NOAAFNFJPFB+0x3C is the + // item's permission (the 06.21 dump names its config row + // CustomAvatarItemPermissionFriendlyNameMapping); this + // schema calls that column ItemType, hence both aliases. + case "itemtype" or "type" or "permission" or "avataritemtype": + req.ItemType = AsInt(value); + break; + case "baseavataritemid" or "baseitemid" or "avataritemid": + req.BaseAvatarItemId = AsInt(value); + break; + case "color" or "colorhex" or "tint": + req.Color = AsString(value); + break; + case "imagename" or "thumbnail" or "thumbnailimage": + req.ImageName = AsString(value); + break; + case "assetname" or "design" or "designname": + req.AssetName = AsString(value); + break; + case "ispublic" or "public": + req.IsPublic = value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => bool.TryParse(value.GetString(), out var b) ? b : null, + _ => null, + }; + break; + } + } } catch (JsonException) { - return new DesignRequest(); + } + + return req; + } + + private static string? AsString(JsonElement value) => value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.ToString(), + _ => null, + }; + + private static int? AsInt(JsonElement value) => value.ValueKind switch + { + JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null, + JsonValueKind.String => int.TryParse(value.GetString(), out var s) ? s : null, + _ => null, + }; + + /// Pick out the create request's two file parts. Named lookup + /// first ("thumbnailImage" / "design", both sent with filename + /// "file.bin" — MPBLNLMCEDL.txt:1372-1390), then positional order as a + /// fallback. + private (IFormFile? Thumbnail, IFormFile? Asset) ReadItemFileParts() + { + if (!Request.HasFormContentType) return (null, null); + var files = Request.Form.Files; + if (files.Count == 0) return (null, null); + var thumbnail = files.GetFile("thumbnailImage") ?? files.GetFile("thumbnail"); + var asset = files.GetFile("design") ?? files.GetFile("asset"); + if (thumbnail is null && asset is null) + { + thumbnail = files[0]; + asset = files.Count > 1 ? files[1] : null; + } + + return (thumbnail?.Length > 0 ? thumbnail : null, asset?.Length > 0 ? asset : null); + } + + /// Persist one uploaded image part and return its BlobName. Same + /// shape as the images controller: S3 (or the disk fallback) holds the + /// bytes, a RoomDataBlobs row keeps the uploader audit trail, and the + /// cdn serves it straight off the BlobName. + private async Task StoreImageBlobAsync(long playerId, IFormFile file, string what) + { + var bytes = await ReadFileBytesAsync(file); + var contentType = SniffImageContentType(bytes); + var blobName = $"img_p{playerId}_{Guid.NewGuid():N}.{(contentType == "image/jpeg" ? "jpg" : "png")}"; + await PutBlobAsync(blobName, bytes, contentType, what); + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = 0, + BlobName = blobName, + UploadedByPlayerId = playerId, + UploadedAt = DateTime.UtcNow, + ReferencedFilenamesCsv = string.Empty, + }); + return blobName; + } + + private static async Task ReadFileBytesAsync(IFormFile file) + { + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + return ms.ToArray(); + } + + /// The parts are sent with filename "file.bin" and no usable + /// content type, so sniff the magic bytes instead of trusting either. + private static string SniffImageContentType(byte[] bytes) => + bytes.Length >= 4 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 ? "image/png" + : bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF ? "image/jpeg" + : "image/png"; + + private async Task PutBlobAsync(string blobName, byte[] bytes, string contentType, string what) + { + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + var (bucket, key) = BlobRouter.Route(blobName); + await storage.PutAsync(bucket, key, bytes, contentType, timeout.Token); + } + catch (Exception ex) + { + logger.LogError(ex, "[customAvatarItems] storing {What} blob {Blob} failed", what, blobName); } } + // ── In-progress shirt design ──────────────────────────────────────────── + // The designer's draft (base item + colour + texture) has no table in this + // schema and adding one is a migration, so it lives in object storage + // under two deterministic per-player blob names: overwrite-in-place on + // save, tombstoned on delete. + private static string DesignMetaBlobName(long playerId) => $"caidesign_p{playerId}.json"; + + private static string DesignImageBlobName(long playerId) => $"caidesign_p{playerId}.png"; + + private async Task LoadDesignAsync(long playerId) + { + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + var (bucket, key) = BlobRouter.Route(DesignMetaBlobName(playerId)); + var bytes = await storage.GetAsync(bucket, key, timeout.Token); + if (bytes is null || bytes.Length == 0) return null; + var design = JsonSerializer.Deserialize( + bytes, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return design is null || design.Deleted ? null : design; + } + catch (Exception ex) when (ex is JsonException or IOException) + { + logger.LogWarning(ex, "[customAvatarItems] unreadable saved design for player {Pid}", playerId); + return null; + } + } + + private Task SaveDesignAsync(long playerId, StoredDesign design) => + PutBlobAsync( + DesignMetaBlobName(playerId), + JsonSerializer.SerializeToUtf8Bytes(design), + "application/json", + "custom avatar design record"); + private object ToWire(CustomAvatarItemEntity item) => new { Id = item.PublicId, @@ -394,6 +1177,42 @@ public class CustomAvatarItemsController( public List? ItemIds { get; set; } } + /// The designer's in-progress draft, as persisted to the + /// per-player caidesign_p<id>.json object (see + /// ). Mirrors the three payload properties of + /// the client's design DTOs — the metadata object GIOBJIGEMNG carries the + /// nullable base-item id and the "#RRGGBB" colour, and the texture arrives + /// as the separate "design" file part whose stored blob name lands in + /// . + /// + /// is the tombstone: + /// exposes no delete operation, so DELETE design overwrites the record with + /// a deleted marker and LoadDesignAsync reports it as "no saved design". + /// Public properties with setters because the round-trip goes through + /// System.Text.Json. + public sealed class StoredDesign + { + public int? BaseAvatarItemId { get; set; } + + public string Color { get; set; } = string.Empty; + + public string ImageName { get; set; } = string.Empty; + + public bool Deleted { get; set; } + } + + /// The three members of the client's report DTO JEHCPEEKMML, + /// in its declaration order: the reason enum (+0x10), the free-text + /// detail (+0x18) and an unidentified Nullable<Int32> (+0x20) that + /// the stock report dialog always leaves null. All nullable so + /// "not sent" is distinguishable from "sent empty". + public sealed class ReportRequest + { + public int? Reason { get; set; } + public string? Details { get; set; } + public int? Context { get; set; } + } + public sealed class DesignRequest { public Guid? Id { get; set; } diff --git a/DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs b/DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs index a43272c..142ee2b 100644 --- a/DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs +++ b/DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs @@ -13,67 +13,123 @@ public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBas { private long Me => this.RequireCurrentPlayerId(); + /// Key prefix for "player X redeemed one of player Y's invites". + /// Row layout is PlayerId = the invitee, + /// Key = externalinvite:redeemed:{kind}:{inviterAccountId}, + /// Value = redemption timestamp. The referrer endpoints below read + /// these rows; the invite-claim path is what writes them. + private const string RedeemedPrefix = "externalinvite:redeemed:"; + + /// Records an out-of-game friend invite aimed at a platform (Steam, + /// Oculus, …) account. + /// + /// The client only ever sends the single form field platformId + /// (RecNet.Runtime/OMHDDLFIPLP.txt:876) — platform keeps its default + /// so the older callers that do send it still bind. [HttpPost("api/externalfriendinvite/v1/createplatforminvite")] public async Task CreatePlatformInvite([FromForm] int platform = 0, [FromForm] string? platformId = null) { - var code = Guid.NewGuid().ToString("N")[..10]; - var key = $"externalinvite:platform:{code}"; + var code = NewInviteCode(); db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, - Key = key, + Key = $"externalinvite:platform:{code}", Value = $"{platform}|{platformId ?? string.Empty}|{DateTime.UtcNow:O}", }); await db.SaveChangesAsync(); var url = $"{Request.Scheme}://{Request.Host}/invite/{code}"; - return Ok(new { InviteCode = code, InviteUrl = url, Platform = platform, PlatformId = platformId ?? string.Empty }); + var result = Success(); + result["InviteCode"] = code; + result["InviteUrl"] = url; + result["Platform"] = platform; + result["PlatformId"] = platformId ?? string.Empty; + return Ok(result); } + /// Records an SMS friend invite. + /// + /// Form fields are phoneNumber, friendCode and + /// senderName (OMHDDLFIPLP.txt:497/505/515) — there is no "message" + /// field, so the friend code that the text is supposed to carry has to be + /// persisted from friendCode or it is lost. [HttpPost("api/externalfriendinvite/v1/sendtextmessageinvite")] - public async Task SendTextMessageInvite([FromForm] string? phoneNumber, [FromForm] string? message) + public async Task SendTextMessageInvite( + [FromForm] string? phoneNumber, + [FromForm] string? friendCode, + [FromForm] string? senderName) { - var code = Guid.NewGuid().ToString("N")[..10]; + var code = NewInviteCode(); db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = $"externalinvite:text:{code}", - Value = $"{phoneNumber ?? string.Empty}|{message ?? string.Empty}|{DateTime.UtcNow:O}", + Value = $"{phoneNumber ?? string.Empty}|{friendCode ?? string.Empty}|{senderName ?? string.Empty}|{DateTime.UtcNow:O}", }); await db.SaveChangesAsync(); - return Ok(new { Success = true, InviteCode = code, PhoneNumber = phoneNumber ?? string.Empty }); + var result = Success(); + result["InviteCode"] = code; + result["PhoneNumber"] = phoneNumber ?? string.Empty; + return Ok(result); } + /// Ids of the accounts that referred the caller through a platform + /// invite. + /// + /// The response is a bare array of account ids — the issuing method is + /// FGLDKEJLAKB<List<System.Int32>> POJMMCCLNCB() + /// (RecNet.Runtime/OMHDDLFIPLP.txt:923-996, body-less POST). Returning + /// objects ({InviteCode, Kind, Value, CreatedAt}) made the strict + /// Int32 reader throw as soon as any row existed, which faulted + /// SessionManager.CheckForAndHandlePlatformFriendOrTextMessageReferrers + /// (SessionManager.txt:31439) during the login bootstrap. [HttpGet("api/externalfriendinvite/v1/getplatformreferrers")] [HttpPost("api/externalfriendinvite/v1/getplatformreferrers")] public async Task GetPlatformReferrers() - { - var rows = await db.PlayerSettings - .Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:platform:")) - .OrderByDescending(s => s.Id) - .ToListAsync(); - return Ok(rows.Select(ToInviteWire)); - } + => Ok(await ReferrerIdsAsync("platform")); + /// Same bare-id-array contract as + /// (OMHDDLFIPLP.txt:998-1071), for + /// text-message invites. [HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")] [HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")] public async Task GetTextMessageReferrers() + => Ok(await ReferrerIdsAsync("text")); + + /// Account ids recorded as having referred the caller with an + /// invite of the given kind. The inviter id is the trailing key segment of + /// the redemption row, so no id can leak in from an invite's own payload + /// (a numeric platformId used to be misread as an account id). + private async Task> ReferrerIdsAsync(string kind) { - var rows = await db.PlayerSettings - .Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:text:")) + var prefix = RedeemedPrefix + kind + ":"; + var keys = await db.PlayerSettings + .Where(s => s.PlayerId == Me && s.Key.StartsWith(prefix)) .OrderByDescending(s => s.Id) + .Select(s => s.Key) .ToListAsync(); - return Ok(rows.Select(ToInviteWire)); + + return keys + .Select(k => int.TryParse(k[prefix.Length..], out var id) ? id : 0) + .Where(id => id > 0) + .Distinct() + .ToList(); } - private static object ToInviteWire(PlayerSettingEntity row) + private static string NewInviteCode() => Guid.NewGuid().ToString("N")[..10]; + + /// Both write routes await FGLDKEJLAKB<DJMHAFPGLLN> and + /// hand it to BFDGEKKNAPB.ExpandRecNetResult, whose lambda pops the + /// Error string as an error toast whenever Success is false + /// (BFDGEKKNAPB_NestedType___c.txt:61 <ExpandRecNetResult>b__2_0). + /// Utf8Json leaves absent members at their default, so a response without + /// these two keys reads as Success=false and every successful invite raised + /// an empty error toast. Key names are literal from the generated formatter + /// (LMCCNLLHLCJ.txt:191 "Success", :210 "Error"; it also accepts the + /// camelCase aliases at :202/:218, and skips members it does not know, so + /// the per-route extras below are inert). + private static Dictionary Success() => new(StringComparer.Ordinal) { - var parts = row.Value.Split('|'); - return new - { - InviteCode = row.Key.Split(':').Last(), - Kind = row.Key.Contains(":text:") ? "text" : "platform", - Value = parts.FirstOrDefault() ?? string.Empty, - CreatedAt = parts.LastOrDefault() ?? string.Empty, - }; - } + ["Success"] = true, + ["Error"] = string.Empty, + }; } diff --git a/DorkNet.Server/Controllers/API/Images/ImagesController.cs b/DorkNet.Server/Controllers/API/Images/ImagesController.cs index 5697a21..221d0ff 100644 --- a/DorkNet.Server/Controllers/API/Images/ImagesController.cs +++ b/DorkNet.Server/Controllers/API/Images/ImagesController.cs @@ -322,6 +322,41 @@ public class ImagesController( return Ok(BuildImageInfo(photo, photo.UploaderPlayerId)); } + /// GET api/images/v6/{id} — single photo by numeric id. + /// The 2023-03-21 client builds the path with + /// String.Format("{0}v6/{1}", "api/images/", id) + /// (KLJOGJHBONK.txt:2694-2698) and passes verb 0 = GET into the + /// request-builder ctor (:2702 Move rdx, 0 → :2708 Call + /// 0x1830036A0); no query fields are attached. + /// + /// The issuing method KLJOGJHBONK.HLANOFILAEO(System.Int64) returns + /// FGLDKEJLAKB<IReadOnlyList<Int32>>, but that is a + /// CLIENT-SIDE projection: a + /// Func<LGLCPNPJCEC, IReadOnlyList<Int32>> + /// (:2756) reduces the wire object to its tagged-player ids. So the + /// body must be ONE LGLCPNPJCEC object — an array breaks the reader — + /// with the 12 keys its generated serializer registers + /// (IBILPLGNAJE.txt:819-1078: Id, ImageName, PlayerId, RoomId, + /// PlayerEventId, Accessibility, AccessibilityLocked, Type, CreatedAt, + /// TaggedPlayerIds, CheerCount, CommentCount), all of which + /// already emits. + /// + /// Without this route the request fell through to + /// api/images/{*path}, which rejects "123" as a non-image filename → + /// 404 on every photo-detail open. Visibility matches the by-name lookup: + /// public photos to anyone, private ones only to their uploader. + [HttpGet("api/images/v6/{id:long}")] + [AllowAnonymous] + public async Task ImageByIdV6(long id) + { + var pid = this.CurrentPlayerId(); + var photo = await db.Photos.FirstOrDefaultAsync(p => + p.Id == id && p.DeletedAt == null && + (p.IsPublic || (pid != null && p.UploaderPlayerId == pid.Value))); + if (photo is null) return NotFound(); + return Ok(BuildImageInfo(photo, photo.UploaderPlayerId)); + } + /// GET api/images/v2/named — the watch's /// Images.DownloadNamedImageMappings endpoint. Returns a /// list of NamedImageDTO entries; an empty list is a valid @@ -522,6 +557,51 @@ public class ImagesController( return Ok(new { deleted = id }); } + /// POST api/images/v1/{id}/report — file a moderation + /// report against a photo from the in-game photo viewer. + /// KLJOGJHBONK.IDAJEMBKAGF(System.Int64) formats the path as + /// String.Format("{0}v1/{1}/report", "api/images/", id) + /// (KLJOGJHBONK.txt:4010-4014) and moves verb 2 = POST into the + /// builder ctor (:4021 Move rdx, 2, host 1 at :4020). + /// + /// The request carries NO fields and NO body — no category, no free text — + /// and the reply is dispatched through BNDIAONDFFF.KDOPJCNKOOK + /// (:4031), which never deserialises it: the call is + /// fire-and-forget, so the client cannot tell a 404 from a 200 and every + /// report was being dropped silently. Everything actionable therefore has + /// to be derived server-side from the photo id, which is why the reported + /// photo's uploader becomes the report target and the photo's room the + /// context. Category 5 = "Other", matching the screenshare reporter's + /// default for reports that arrive without one. + [HttpPost("api/images/v1/{id:long}/report")] + [Authorize] + public async Task ReportPhoto(long id) + { + var reporter = this.RequireCurrentPlayerId(); + var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id && p.DeletedAt == null); + if (photo is null) return NotFound(); + + // ReportEntity has no TargetPhotoId column, so the photo id + blob name + // ride in the message text the same way club reports carry "[club {id}]" + // — a moderator needs the actual image to act on the report. + var message = $"[photo {photo.Id} {photo.BlobName}] reported from the in-game photo viewer"; + db.Reports.Add(new ReportEntity + { + ReporterPlayerId = reporter, + TargetPlayerId = photo.UploaderPlayerId, + RoomId = photo.RoomId, + Category = 5, + Message = message[..Math.Min(1000, message.Length)], + }); + await db.SaveChangesAsync(); + + logger.LogInformation( + "[images] photo {PhotoId} reported by {Reporter} (uploader {Uploader}, room {RoomId})", + photo.Id, reporter, photo.UploaderPlayerId, photo.RoomId); + + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + // ── Legacy v1 ops (real persistence, was Ack-only stubs) ───────── public sealed class CheerImageRequest @@ -530,23 +610,48 @@ public class ImagesController( public long PhotoId { get; set; } } - public sealed class BulkCheeredRequest - { - public List? SavedImageIds { get; set; } - public List? ImageIds { get; set; } - } + /// Field names the cheer-state batch accepts. The 2023-03-21 + /// client sends the singular "id" + /// (KLJOGJHBONK.txt:3763 Move rdx, "id"); the older spellings are + /// kept so the 2020.12 watch and our own tooling keep working. + private static readonly string[] CheerBulkIdKeys = + { "id", "ids", "SavedImageId", "SavedImageIds", "ImageId", "ImageIds" }; + /// GET — and POST once the id list reaches 100 — for + /// api/images/v{4,5}/cheered/bulk: the caller's cheer state for a + /// batch of photos (the hearts on the photo feed). + /// + /// VERB: the client picks it at runtime. + /// KLJOGJHBONK.JCLGICHPPGB(IEnumerable<Int64>) calls + /// ALHIJCJOLCB.JIECAFGCODK(count, 100) and moves the result straight + /// into the request-builder's verb register + /// (KLJOGJHBONK.txt:3747 Call ALHIJCJOLCB.JIECAFGCODK → + /// :3755 Move rdx, rdi → :3753 Move r9, + /// "api/images/v5/cheered/bulk") — GET (0) for short lists, POST (2) for + /// long ones. Registering POST only meant the common case fell into the + /// api/images/{*path} byte catch-all and 404'd, so cheer state never + /// loaded. + /// + /// REQUEST: one field, "id", added via + /// BNDIAONDFFF.AFGEDDANEKP("id", ids) (KLJOGJHBONK.txt:3763). + /// The builder emits fields as query values on GET and form values on POST — + /// never as a JSON body — so the ids are read straight off the request + /// instead of through [FromBody], which would 415 the form POST + /// before the action ran. + /// + /// RESPONSE: List<LAKGLIDCEDE>, i.e. + /// [{"SavedImageId":Int64,"IsCheered":Boolean}] — key literals in the + /// generated serializer at KJEIGGBIIHP.txt:215,242. One entry per + /// requested id so the client's cache has no holes. + [HttpGet("api/images/v4/cheered/bulk")] + [HttpGet("api/images/v5/cheered/bulk")] [HttpPost("api/images/v4/cheered/bulk")] [HttpPost("api/images/v5/cheered/bulk")] [Authorize] - public async Task CheeredBulk([FromBody] BulkCheeredRequest? body) + public async Task CheeredBulk() { var pid = this.RequireCurrentPlayerId(); - var ids = (body?.SavedImageIds ?? body?.ImageIds ?? new()) - .Where(id => id > 0) - .Distinct() - .Take(200) - .ToList(); + var ids = await ReadCheerBulkIdsAsync(); if (ids.Count == 0) return Ok(Array.Empty()); var cheered = await db.Cheers @@ -561,6 +666,82 @@ public class ImagesController( })); } + /// Collect the cheer-state batch ids from wherever this client + /// put them: query values on the GET path, form values on the POST path + /// (both produced by BNDIAONDFFF's field list), or a JSON body for + /// non-game callers. Repeated fields and comma-joined values are both + /// accepted because the builder's query assembler emits one + /// &id= per element while our own admin tooling sends a single + /// comma-separated value. + private async Task> ReadCheerBulkIdsAsync() + { + var ids = new List(); + + void Collect(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return; + foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (long.TryParse(part, out var id) && id > 0) ids.Add(id); + } + + foreach (var key in CheerBulkIdKeys) + foreach (var value in Request.Query[key]) + Collect(value); + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var key in CheerBulkIdKeys) + foreach (var value in form[key]) + Collect(value); + } + else if (Request.ContentLength is > 0) + { + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await JsonDocument.ParseAsync(Request.Body, cancellationToken: HttpContext.RequestAborted); + Request.Body.Position = 0; + CollectJsonIds(doc.RootElement, Collect); + } + catch (JsonException) + { + // Not JSON — the query/form pass above is authoritative. + } + } + + return ids.Distinct().Take(500).ToList(); + } + + /// Pull ids out of a JSON body that is either a bare array of + /// numbers or an object keyed by one of . + private static void CollectJsonIds(JsonElement root, Action collect) + { + switch (root.ValueKind) + { + case JsonValueKind.Array: + foreach (var item in root.EnumerateArray()) CollectJsonIds(item, collect); + break; + case JsonValueKind.Object: + foreach (var key in CheerBulkIdKeys) + { + foreach (var prop in root.EnumerateObject()) + { + if (!string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase)) continue; + CollectJsonIds(prop.Value, collect); + } + } + break; + case JsonValueKind.Number: + collect(root.GetRawText()); + break; + case JsonValueKind.String: + collect(root.GetString()); + break; + } + } + /// POST api/images/v1/cheer — cheer a photo. /// Was previously Ack-only; now persists a CheerEntity row /// keyed on TargetPhotoId + bumps Photos.CheerCount. @@ -618,6 +799,16 @@ public class ImagesController( public long ImageId { get; set; } public string? ImageName { get; set; } public bool IsPublic { get; set; } + + /// What the 2023-03-21 client actually sends: + /// 0 = Private, 1 = Public, 2 = FriendsOnly. It never sends + /// , so reading only that flag left every photo + /// private no matter what the player picked. + public int? Accessibility { get; set; } + + /// Resolved visibility: the enum when present, else the + /// legacy boolean. + public bool ResolvedIsPublic => Accessibility is int a ? a == 1 : IsPublic; } /// POST api/images/v1/modifyaccessibility — flip @@ -630,7 +821,8 @@ public class ImagesController( [FromForm(Name = "PhotoId")] long? photoIdForm, [FromForm(Name = "ImageId")] long? imageIdForm, [FromForm(Name = "ImageName")] string? imageNameForm, - [FromForm(Name = "IsPublic")] bool? isPublicForm) + [FromForm(Name = "IsPublic")] bool? isPublicForm, + [FromForm(Name = "Accessibility")] int? accessibilityForm) { var pid = this.RequireCurrentPlayerId(); var photoId = body?.PhotoId ?? body?.ImageId @@ -638,7 +830,12 @@ public class ImagesController( var imageName = body?.ImageName ?? imageNameForm; if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName)) return BadRequest(new { error = "missing photoId" }); - var isPublic = body?.IsPublic ?? isPublicForm ?? true; + // Accessibility (0=Private, 1=Public, 2=FriendsOnly) is what the 2023 + // client sends; IsPublic is the older boolean. + var accessibility = body?.Accessibility ?? accessibilityForm; + var isPublic = accessibility is int a + ? a == 1 + : body?.IsPublic ?? isPublicForm ?? true; var photo = photoId > 0 ? await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId) diff --git a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs index c46423c..bfc06a2 100644 --- a/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs +++ b/DorkNet.Server/Controllers/API/Inventions/InventionsController.cs @@ -41,8 +41,6 @@ public class InventionsController( // ── Browse ─────────────────────────────────────────────────────────── [HttpGet("api/inventions/v1/featured")] - [HttpGet("api/inventions/v1/featureddormskins")] - [HttpGet("api/inventions/v1/toptoday")] [HttpGet("api/inventions/v3/popular")] public async Task Popular([FromQuery] int take = 50) { @@ -55,6 +53,82 @@ public class InventionsController( return Ok(rows.Select(ToWire)); } + /// GET api/inventions/v1/toptoday — the client fires + /// this as its own no-arg shelf query + /// (OEGFNFEAAGO_NestedType___c_NestedType___GetTopInventionsToday_b__43_0_d.txt:147, + /// verb 0) and shows it NEXT TO the featured shelf, so aliasing it onto + /// the all-time popular query rendered the same twelve tiles twice. + /// Ranked by cheers received in the last 24h, then backfilled with + /// all-time popular so a quiet day still fills the shelf. + [HttpGet("api/inventions/v1/toptoday")] + public async Task TopToday([FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 100); + var since = DateTime.UtcNow.AddDays(-1); + var hot = await db.Cheers + .Where(c => c.TargetInventionId > 0 && c.CheeredAt >= since) + .GroupBy(c => c.TargetInventionId) + .Select(g => new { InventionId = g.Key, Score = g.Count() }) + .OrderByDescending(x => x.Score) + .Take(take) + .ToListAsync(); + + var order = hot.Select(h => h.InventionId).ToList(); + var hits = order.Count == 0 + ? new List() + : await db.Inventions + .Where(i => !i.IsDeleted && i.IsPublished && order.Contains(i.Id)) + .ToListAsync(); + + var ranked = order + .Select(id => hits.FirstOrDefault(r => r.Id == id)) + .Where(r => r is not null) + .Select(r => r!) + .ToList(); + + if (ranked.Count < take) + { + var have = ranked.Select(r => r.Id).ToList(); + var filler = await db.Inventions + .Where(i => !i.IsDeleted && i.IsPublished && !have.Contains(i.Id)) + .OrderByDescending(i => i.CheerCount) + .Take(take - ranked.Count) + .ToListAsync(); + ranked.AddRange(filler); + } + return Ok(ranked.Select(ToWire)); + } + + /// GET api/inventions/v1/featureddormskins — separate + /// shelf in the client + /// (OEGFNFEAAGO_NestedType___c_NestedType___GetFeaturedDormSkins_b__45_0_d.txt:147, + /// verb 0, no params) which previously returned generic popular + /// inventions. has no dorm-skin column, so + /// the marker lives in — see + /// . The filter runs in memory because + /// TagsCsv is a packed list no provider can split. + [HttpGet("api/inventions/v1/featureddormskins")] + public async Task FeaturedDormSkins([FromQuery] int take = 50) + { + take = Math.Clamp(take, 1, 100); + var rows = await db.Inventions + .Where(i => !i.IsDeleted && i.IsPublished && i.TagsCsv != "") + .OrderByDescending(i => i.CheerCount) + .Take(500) + .ToListAsync(); + return Ok(rows.Where(i => IsDormSkinTags(i.TagsCsv)).Take(take).Select(ToWire)); + } + + /// Dorm-skin marker. There is no IsDormSkin column on + /// , so a tag decides: any tag that reads + /// "dormskin" / "dorm skin" (case- and space-insensitive). + private static bool IsDormSkinTags(string? tagsCsv) => + !string.IsNullOrEmpty(tagsCsv) + && tagsCsv + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(t => t.Replace(" ", string.Empty) + .Equals("dormskin", StringComparison.OrdinalIgnoreCase)); + [HttpGet("api/inventions/v3/saved")] [Authorize] public async Task Saved([FromQuery] int take = 50) @@ -80,10 +154,20 @@ public class InventionsController( [Authorize] public Task Mine() => Saved(100); + /// The 2023 client sends value + skip + take on every store + /// search (OEGFNFEAAGO.txt:10805/10820/10832 — three AFGEDDANEKP pairs + /// on a verb-0 request). Ignoring skip/take pinned every page to the + /// first 50 rows, so scrolling the store re-showed the same tiles. [HttpGet("api/inventions/v1/search")] [HttpGet("api/inventions/v2/search")] - public async Task Search([FromQuery] string value = "") + public async Task Search( + [FromQuery] string value = "", + [FromQuery] int skip = 0, + [FromQuery] int take = 100) { + skip = Math.Max(0, skip); + take = Math.Clamp(take, 1, 200); + if (string.IsNullOrWhiteSpace(value)) return Ok(Array.Empty()); @@ -112,7 +196,8 @@ public class InventionsController( .Where(i => !i.IsDeleted && i.IsPublished && creators.Contains(i.CreatorPlayerId)) .OrderByDescending(i => i.CheerCount) - .Take(50) + .Skip(skip) + .Take(take) .ToListAsync(); return Ok(byCreator.Select(ToWire)); } @@ -123,36 +208,135 @@ public class InventionsController( && (i.Name.ToLower().Contains(v) || i.TagsCsv.ToLower().Contains(v))) .OrderByDescending(i => i.CheerCount) - .Take(50) + .Skip(skip) + .Take(take) .ToListAsync(); return Ok(rows.Select(ToWire)); } - public sealed record InventionBatchRequest(List InventionIds); + // ── Id-list endpoints (runtime GET/POST) ───────────────────────────── + // + // The 2023 client picks the verb at RUNTIME for every id-list route: + // ALHIJCJOLCB.JIECAFGCODK (IsilDump/RecNet.Runtime/ALHIJCJOLCB.txt:3198) + // is `count < limit ? GET : POST` with limit=100. On a non-GET the + // key/value pairs move out of the query string and into an + // HTTPUrlEncodedForm body — BNDIAONDFFF.txt:2971-3010 branches on the + // verb (GET → "?a=b&…", otherwise → HTTPUrlEncodedForm). So the POST + // actions must NOT declare a complex parameter, or [ApiController] + // infers [FromBody] and answers 415 before the handler runs; we read + // query + form by hand instead. + // + // Field names are literal: + // "id" → v2/batch (OEGFNFEAAGO_NestedType_LANMKGKDNDC.txt:228) + // "id" → fromcreators (…CDILMPCKMEO…GetInventionsByCreators_b__0_d.txt:330) + // "ids" → dormskinsfromids (OEGFNFEAAGO.txt:5913) - [HttpPost("api/inventions/v1/batch")] - [HttpPost("api/inventions/v2/batch")] - [HttpPost("api/inventions/v1/dormskinsfromids")] - public async Task Batch([FromBody] InventionBatchRequest req) + /// Body shape for non-2023 callers, which still send a JSON + /// {"InventionIds":[…]} envelope rather than repeated fields. + private sealed class InventionIdsBody { - return await BatchIdsAsync(req?.InventionIds ?? []); + public List? InventionIds { get; set; } + public List? Ids { get; set; } + public List? Id { get; set; } } + private static readonly System.Text.Json.JsonSerializerOptions IdsJson = + new() { PropertyNameCaseInsensitive = true }; + + /// Gather ids from the query string, the url-encoded form and + /// (as a fallback) a JSON id envelope. See the block comment above for + /// why all three have to be accepted on one route. + private async Task> CollectIdsAsync(params string[] keys) + { + var ids = new List(); + + void Take(string? raw) + { + if (string.IsNullOrEmpty(raw)) return; + foreach (var part in raw.Split( + ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (long.TryParse(part, out var id) && id > 0) ids.Add(id); + } + } + + var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null; + foreach (var key in keys) + { + foreach (var v in Request.Query[key]) Take(v); + if (form is not null) + foreach (var v in form[key]) Take(v); + } + + if (ids.Count == 0 && form is null + && (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) ?? false)) + { + try + { + var body = await System.Text.Json.JsonSerializer + .DeserializeAsync(Request.Body, IdsJson); + foreach (var list in new[] { body?.InventionIds, body?.Ids, body?.Id }) + { + if (list is not null) ids.AddRange(list.Where(id => id > 0)); + } + } + catch (System.Text.Json.JsonException) + { + // Not an id envelope — treat as "no ids" rather than 400. + } + } + + return ids.Distinct().Take(200).ToList(); + } + + /// Read one scalar field from the query string, falling back to + /// the url-encoded form body (POST variants — see the block comment + /// above). + private async Task ReadFieldAsync(string key) + { + var fromQuery = Request.Query[key].FirstOrDefault(); + if (!string.IsNullOrEmpty(fromQuery)) return fromQuery; + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + var fromForm = form[key].FirstOrDefault(); + if (!string.IsNullOrEmpty(fromForm)) return fromForm; + } + return null; + } + + private async Task ReadIntFieldAsync(string key) + => int.TryParse(await ReadFieldAsync(key), out var v) ? v : null; + + private async Task ReadLongFieldAsync(string key) + => long.TryParse(await ReadFieldAsync(key), out var v) ? v : null; + [HttpGet("api/inventions/v1/batch")] [HttpGet("api/inventions/v2/batch")] - [HttpGet("api/inventions/v1/dormskinsfromids")] - public async Task BatchFromQuery() - { - var ids = Request.Query - .SelectMany(q => q.Value) - .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - .Select(v => long.TryParse(v, out var id) ? id : 0L) - .Where(id => id > 0) - .Distinct() - .Take(200) - .ToList(); + [HttpPost("api/inventions/v1/batch")] + [HttpPost("api/inventions/v2/batch")] + public async Task Batch() + => await BatchIdsAsync(await CollectIdsAsync("id", "ids", "InventionIds")); - return await BatchIdsAsync(ids); + /// api/inventions/v1/dormskinsfromids — the client + /// deserialises a BARE List<Int64> here, not a list of + /// invention objects: the continuation at OEGFNFEAAGO.txt:5941 is typed + /// FGLDKEJLAKB<List`1<Int64>> and is then projected by a + /// Func<List<Int64>, List<Int64>> (:5966). The reply is the + /// SUBSET of the posted ids that are dorm skins; aliasing this onto the + /// generic batch handler fed objects to a number reader and killed + /// dorm-skin filtering outright. + [HttpGet("api/inventions/v1/dormskinsfromids")] + [HttpPost("api/inventions/v1/dormskinsfromids")] + public async Task DormSkinsFromIds() + { + var ids = await CollectIdsAsync("ids", "id", "InventionIds"); + if (ids.Count == 0) return Ok(Array.Empty()); + var rows = await db.Inventions + .Where(i => !i.IsDeleted && ids.Contains(i.Id)) + .Select(i => new { i.Id, i.TagsCsv }) + .ToListAsync(); + return Ok(rows.Where(r => IsDormSkinTags(r.TagsCsv)).Select(r => r.Id).ToList()); } private async Task BatchIdsAsync(IReadOnlyCollection requestedIds) @@ -167,22 +351,24 @@ public class InventionsController( } [HttpGet("api/inventions/v1/fromcreators")] + [HttpPost("api/inventions/v1/fromcreators")] public async Task FromCreators() { - var creatorIds = Request.Query - .SelectMany(q => q.Value) - .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - .Select(v => long.TryParse(v, out var id) ? id : 0L) - .Where(id => id > 0) - .Distinct() - .Take(200) - .ToList(); - + // Only the literal "id" key carries creator ids. The previous + // SelectMany-over-every-query-field also swallowed the client's + // "skip"/"take" pairs, so take=100 quietly added player 100's + // inventions to the shelf. + var creatorIds = await CollectIdsAsync("id"); if (creatorIds.Count == 0) return Ok(Array.Empty()); + + var skip = Math.Max(0, await ReadIntFieldAsync("skip") ?? 0); + var take = Math.Clamp(await ReadIntFieldAsync("take") ?? 100, 1, 200); + var rows = await db.Inventions .Where(i => !i.IsDeleted && i.IsPublished && creatorIds.Contains(i.CreatorPlayerId)) .OrderByDescending(i => i.UpdatedAt) - .Take(200) + .Skip(skip) + .Take(take) .ToListAsync(); return Ok(rows.Select(ToWire)); } @@ -201,19 +387,59 @@ public class InventionsController( /// List → Dictionary cast inside Util.Deserialize<T> /// and surfaced as "Failed to get tags: Malformed Response" /// (output_log.txt:1602+1639) — and that bubbles up as the - /// dorm-load destabilisation we've been hunting. + /// dorm-load destabilisation we've been hunting. + /// + /// The 2023 DTO reads a THIRD list, TrendingFilters + /// (HMDONHHKKGA.txt:099/169), which we never sent — callers projecting that + /// list got an empty filter row. Popular/Trending are now derived from the + /// tags actually in use () rather than + /// echoing the pinned array three times; the curated list is the fallback + /// so a fresh server still shows filters. The 2020.12 watch's + /// AEBEPCMAABC reads only Pinned/Popular, so the extra key is + /// inert there. [HttpGet("api/inventions/v1/tagfilters")] - public IActionResult TagFilters() + public async Task TagFilters() { - var tags = new[] - { + string[] pinned = + [ "sport", "game", "vehicle", "weapon", "decor", "tool", "art", "music", "puzzle", "combat", "build", "race", - }; + ]; + + // TagsCsv is a packed list no provider can split, so the frequency count + // runs in memory over a bounded window of the most recently touched + // tagged inventions. + var rows = await db.Inventions + .Where(i => !i.IsDeleted && i.IsPublished && i.TagsCsv != "") + .OrderByDescending(i => i.UpdatedAt) + .Select(i => new { i.TagsCsv, i.UpdatedAt }) + .Take(2000) + .ToListAsync(); + + static IEnumerable SplitTags(string csv) => csv + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + static List Rank(IEnumerable tags) => tags + .GroupBy(t => t, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(g => g.Count()) + .ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .Select(g => g.Key) + .Take(12) + .ToList(); + + var since = DateTime.UtcNow.AddDays(-7); + var popular = Rank(rows.SelectMany(r => SplitTags(r.TagsCsv))); + var trending = Rank(rows.Where(r => r.UpdatedAt >= since) + .SelectMany(r => SplitTags(r.TagsCsv))); + + if (popular.Count == 0) popular = [.. pinned]; + if (trending.Count == 0) trending = popular; + return Ok(new { - PinnedFilters = tags, - PopularFilters = tags, + PinnedFilters = pinned, + PopularFilters = popular, + TrendingFilters = trending, }); } @@ -334,18 +560,26 @@ public class InventionsController( return Ok(ToWire(i)); } - /// POST api/storefronts/v1/trialInvention?inventionId={id} - /// — start a free trial of a store invention. Client - /// (RecNet.Runtime DCFKEFHJAGC.PPCLLFMHJLD(long)) expects a SINGLE - /// Invention DTO back (same shape it uses for - /// search), which it spawns for the trial window (length from - /// trialInvention/duration). Hosted here — not in + /// POST api/storefronts/v1/trialInvention — start a free + /// trial of a store invention, spawned for the window returned by + /// trialInvention/duration. Hosted here — not in /// StorefrontsController — so it can reuse the invention wire builder. - /// We record the trial start so it's an auditable action. + /// We record the trial start so it's an auditable action. + /// + /// TWO wire corrections. (1) The id is a FORM field, not a query param: + /// DCFKEFHJAGC.txt:9037 sets verb 2 (POST) and :9050 adds the pair via + /// AFGEDDANEKP("inventionId", …), and on a non-GET those pairs go + /// into an HTTPUrlEncodedForm body (see the id-list block comment above), so + /// [FromQuery] bound 0 and every trial 404'd. (2) The reply is the + /// Status/Invention/InventionVersion envelope: the continuation at + /// DCFKEFHJAGC.txt:9086 is a + /// Func<BDNCJIPHHOK, FGLDKEJLAKB<IFJONDCAKKM>> which + /// projects .Invention — a bare invention left that null. [HttpPost("api/storefronts/v1/trialInvention")] [Authorize] - public async Task TrialInvention([FromQuery] long inventionId) + public async Task TrialInvention() { + var inventionId = await ReadLongFieldAsync("inventionId") ?? 0; var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); if (i is null) return NotFound(); if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull) @@ -364,9 +598,21 @@ public class InventionsController( row.Value = DateTime.UtcNow.ToString("O"); await db.SaveChangesAsync(); } - return Ok(ToWire(i)); + return Ok(await EnvelopeAsync(i)); } + /// GET api/inventions/v1/details — the ONLY key either + /// client reads off this response is Tags: the 2023 continuation is + /// Action<OIABGAKJABE> (OEGFNFEAAGO.txt:6312) whose reader takes + /// exactly one literal, "Tags" (DBAHPLOPFIO.txt:034), and the 2020.12 watch's + /// HJPDBNLCGIB does the same. Each element is + /// {Tag:String, Type:Int32} (PCMLHLIBLNJ.txt:038/057). Without the key + /// the invention detail page's tag row was permanently empty. Invention + + /// Versions stay on the wire — unknown members are ignored by both readers. + /// Type is always 0: merges the client's + /// AutoTags and CustomTags into one + /// column, so the auto/custom distinction is not recoverable without a + /// second column. [HttpGet("api/inventions/v1/details")] public async Task Details([FromQuery] long inventionId) { @@ -380,21 +626,33 @@ public class InventionsController( .ToListAsync(); return Ok(new { + Tags = i.TagsCsv + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(t => new { Tag = t, Type = 0 }), Invention = ToWire(i), Versions = versions.Select(ToVersionWire), }); } + /// GET api/inventions/v1/personaldetails/{id} — the caller's + /// own relationship to the invention. Both clients read a single key here: + /// the 2023 continuation is FGLDKEJLAKB<CEAFHBOOBKL> + /// (OEGFNFEAAGO.txt:8995) and CEAFHBOOBKL's reader takes only "IsCheering" + /// (BCLCHNGENDE.txt:036); the 2020.12 watch's OEGPIPBKHCN is identical. + /// Without it the detail page never lit the player's own cheer. [HttpGet("api/inventions/v1/personaldetails/{id:long}")] [Authorize] public async Task PersonalDetails(long id) { var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted); if (i is null) return NotFound(); + var pid = CurrentPlayerId; return Ok(new { + IsCheering = await db.Cheers.AnyAsync(c => + c.FromPlayerId == pid && c.TargetInventionId == id), Invention = ToWire(i), - CanEdit = i.CreatorPlayerId == CurrentPlayerId, + CanEdit = i.CreatorPlayerId == pid, }); } @@ -427,6 +685,14 @@ public class InventionsController( public string? ImageName { get; set; } public int InstantiationCost { get; set; } public int LightsCost { get; set; } + /// RecNet.NewInventionRequestDTO carries chipsCost and + /// cloudVariablesCost too (2023.06 dump.cs:1283258-1283259). + /// has no column for either, so + /// they cannot be persisted — but binding them lets the save response + /// echo back the budget the client just reported instead of two + /// zeroes. + public int ChipsCost { get; set; } + public int CloudVariablesCost { get; set; } public int AiCost { get; set; } public long? CreationRoomId { get; set; } public string? InventionDataFilename { get; set; } @@ -500,13 +766,30 @@ public class InventionsController( { Status = 0, Invention = ToWireV4(inv), - InventionVersion = ToVersionWire(v1), + InventionVersion = ToVersionWire( + v1, Math.Max(0, req.ChipsCost), Math.Max(0, req.CloudVariablesCost)), }); } + /// The 2023-03-21 client names the blob field + /// inventionDataFilename and sends four extra cost fields plus the + /// creation room and referenced inventions. The record only had + /// BlobName, which the client never sends, so the required-parameter + /// check rejected every publish with 400 and no new invention version could + /// ever be saved. public sealed record AddVersionRequest( - long InventionId, string BlobName, - int? InstantiationCost, int? LightsCost); + long InventionId, + string? BlobName, + string? InventionDataFilename, + int? InstantiationCost, int? LightsCost, + int? ChipsCost, int? CloudVariablesCost, int? AiCost, + long? CreationRoomId, + List? ReferencedInventions) + { + public string? Blob => !string.IsNullOrWhiteSpace(InventionDataFilename) + ? InventionDataFilename + : BlobName; + } [HttpPost("api/inventions/v3/addversion")] [HttpPost("api/inventions/v4/addversion")] @@ -517,24 +800,36 @@ public class InventionsController( var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted); if (inv is null) return NotFound(); if (inv.CreatorPlayerId != pid) return Forbid(); - if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName"); + var blobName = req.Blob; + if (string.IsNullOrWhiteSpace(blobName)) return BadRequest("missing inventionDataFilename"); var nextVer = inv.CurrentVersionNumber + 1; - inv.CurrentBlobName = req.BlobName; + inv.CurrentBlobName = blobName; inv.CurrentVersionNumber = nextVer; inv.UpdatedAt = DateTime.UtcNow; - db.InventionVersions.Add(new InventionVersionEntity + var version = new InventionVersionEntity { InventionId = inv.Id, ReplicationId = Guid.NewGuid().ToString("D"), VersionNumber = nextVer, - BlobName = req.BlobName, + BlobName = blobName, InstantiationCost = req.InstantiationCost ?? 0, LightsCost = req.LightsCost ?? 0, - }); + }; + db.InventionVersions.Add(version); await db.SaveChangesAsync(); - return Ok(ToWire(inv)); + + // Envelope, not a bare invention — see EnvelopeAsync. The version we + // just wrote is the one the client wants back, and the two cost fields + // it sent are echoed because no column stores them. + return Ok(new + { + Status = 0, + Invention = ToWire(inv), + InventionVersion = ToVersionWire( + version, Math.Max(0, req.ChipsCost ?? 0), Math.Max(0, req.CloudVariablesCost ?? 0)), + }); } // ── GET-based mutations (Core.Get pattern) ─────────────────────────── @@ -572,14 +867,42 @@ public class InventionsController( } inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - return Ok(ToWire(inv)); + return Ok(await EnvelopeAsync(inv)); } + /// Body shape for the POST form of updateprice — + /// RecNet.UpdatePriceRequest, real field names preserved in the 2023.06 + /// dump (dump.cs:1283355-1283356). Settable properties + a parameterless + /// ctor because constructs the + /// instance itself. + public sealed class UpdatePriceRequest + { + public long InventionId { get; set; } + public int Price { get; set; } + } + + /// Both clients POST updateprice with a body; only the GET + /// query form was registered, so ASP.NET answered 405 and re-pricing a paid + /// invention always failed. 2023: OEGFNFEAAGO.txt:9850 sets verb 2 and + /// :9870-9876 hands JsonUtility.ToJson(UpdatePriceRequest) to + /// FJLLPHFOOJJ, which wraps it in a BestHTTP RawJsonForm + /// (application/json, RawJsonForm.txt:40). December POSTs the same + /// route form-encoded (2020.12 BBHENFCNLAB.txt:6480). The binder accepts + /// both; the GET stays for callers that still use the query string. [HttpGet("api/inventions/v1/updateprice")] [Authorize] - public async Task UpdatePrice( + public Task UpdatePrice( [FromQuery] long inventionId, [FromQuery] int price) + => ApplyPriceAsync(inventionId, price); + + [HttpPost("api/inventions/v1/updateprice")] + [Authorize] + public Task UpdatePricePost( + [ModelBinder(typeof(Binding.FormOrJsonModelBinder))] UpdatePriceRequest req) + => ApplyPriceAsync(req.InventionId, req.Price); + + private async Task ApplyPriceAsync(long inventionId, int price) { var pid = CurrentPlayerId; var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); @@ -588,7 +911,7 @@ public class InventionsController( inv.Price = Math.Clamp(price, 0, 1000000); inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - return Ok(ToWire(inv)); + return Ok(await EnvelopeAsync(inv)); } [HttpGet("api/inventions/v1/delete")] @@ -604,13 +927,29 @@ public class InventionsController( inv.IsPublished = false; inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - return Ok(ToWire(inv)); + return Ok(await EnvelopeAsync(inv)); } + /// The 2023 publish call carries FOUR query params, not two: + /// OEGFNFEAAGO.txt:9437/9449/9461/9473 add inventionId, + /// permissionLevel, accessibility and a + /// Nullable<Int32> price — the wrapper signature is + /// OEGFNFEAAGO.txt:9216. Dropping accessibility meant "publish as Private / + /// Unlisted" still went fully public, and a paid publish lost its price. + /// Accessibility constants: Private=0, Public=1, Unlisted=2 + /// (AEFFFPIJDHG.GAKJKOGJEEH, 2023.06 dump.cs:1282644-1282646). With no + /// Accessibility column on the entity only the Private/not-Private half can + /// be stored, so Unlisted currently behaves as Public. The param is + /// NULLABLE on purpose — the 2020.12 watch sends only inventionId + + /// permissionLevel (2020.12 BBHENFCNLAB.txt:6171), and a missing value must + /// not be read as Private or December could never publish. [HttpGet("api/inventions/v3/publish")] [Authorize] public async Task Publish( - [FromQuery] long inventionId, [FromQuery] int permissionLevel) + [FromQuery] long inventionId, + [FromQuery] int permissionLevel, + [FromQuery] int? accessibility, + [FromQuery] int? price) { var pid = CurrentPlayerId; var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted); @@ -618,13 +957,15 @@ public class InventionsController( if (inv.CreatorPlayerId != pid) return Forbid(); var perm = ClampInventionPermission(permissionLevel); if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)"); + var access = accessibility ?? 1 /* Public */; inv.GeneralPermission = perm; - inv.IsPublished = true; - inv.FirstPublishedAt ??= DateTime.UtcNow; + inv.IsPublished = access != 0; + if (inv.IsPublished) inv.FirstPublishedAt ??= DateTime.UtcNow; inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0); + if (price.HasValue) inv.Price = Math.Clamp(price.Value, 0, 1000000); inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - return Ok(ToWire(inv)); + return Ok(await EnvelopeAsync(inv)); } [HttpGet("api/inventions/v1/unpublish")] @@ -640,7 +981,7 @@ public class InventionsController( inv.Permission = 0; inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - return Ok(ToWire(inv)); + return Ok(await EnvelopeAsync(inv)); } [HttpGet("api/inventions/v1/download")] @@ -664,8 +1005,33 @@ public class InventionsController( // ── POST mutations ─────────────────────────────────────────────────── - public sealed record SetTagsRequest(long InventionId, string? AutoTags, string? PlayerAddedTags); + /// The client sends AutoTags and CustomTags as JSON + /// ARRAYS of strings. Binding them to string made deserialization + /// throw, so every tag edit 400'd. PlayerAddedTags is kept as an + /// alias for older callers. Field names are literal in the 2023.06 dump — + /// RecNet.ModifyTagsRequest keeps real names (dump.cs:1283295-1283301) — + /// and the body is a RAW JSON document + /// (JsonUtility.ToJson → BNDIAONDFFF.FJLLPHFOOJJ, + /// OEGFNFEAAGO.txt:3082-3088), so [FromBody] is right here. + public sealed record SetTagsRequest( + long InventionId, + List? AutoTags, + List? CustomTags, + List? PlayerAddedTags); + /// POST api/inventions/v1/settags — the reply is NOT an + /// invention. The 2023 issuing method is typed + /// FGLDKEJLAKB<PNGLFHEAJIH> (OEGFNFEAAGO.txt:2744) and + /// PNGLFHEAJIH exposes exactly two members — an int-backed result enum and + /// List<String> (PNGLFHEAJIH.txt:3/83) — read from the literals + /// "Result" and "Tags" (ALJPHDEAHBK.txt:191/210). The 2020.12 watch is + /// identical: NJMAEIPIOAP.PPGFHEDFBEA pulls "Result" then "Tags" + /// (2020.12 NJMAEIPIOAP.txt:85/90) with the throwing Util.GetKey, so the + /// old bare-invention reply killed every tag edit on December too. + /// Result = 0 is success: the enum's message formatter jump-tables on + /// the value and case 0 returns "Success!" (PNGLFHEAJIH.txt:334-341). + /// Tags is a flat string list — NOT the {Tag,Type} pairs + /// returns. [HttpPost("api/inventions/v1/settags")] [Authorize] public async Task SetTags([FromBody] SetTagsRequest req) @@ -674,16 +1040,39 @@ public class InventionsController( var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted); if (inv is null) return NotFound(); if (inv.CreatorPlayerId != pid) return Forbid(); - var combined = string.Join(',', new[] { req.AutoTags, req.PlayerAddedTags } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(','); - inv.TagsCsv = combined; + var tags = new[] { req.AutoTags, req.CustomTags, req.PlayerAddedTags } + .Where(list => list is not null) + .SelectMany(list => list!) + .Select(t => (t ?? string.Empty).Trim()) + .Where(t => t.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + inv.TagsCsv = string.Join(',', tags); inv.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - return Ok(ToWire(inv)); + return Ok(new { Result = 0, Tags = tags }); } - public sealed record CheerRequest(long InventionId); + /// RecNet.CheerRequest carries a SECOND field — Cheer, the + /// desired state — whose real name survives in the 2023.06 dump + /// (dump.cs:1283338-1283342: long InventionId @0x10, + /// bool Cheer @0x18). Both writes are visible in the ISIL: 2023 sets + /// [rdi+16] and [rdi+24] before JsonUtility.ToJson + /// (OEGFNFEAAGO.txt:11228-11233) and December does the same + /// (2020.12 BBHENFCNLAB.txt:7624-7628) — its wrapper signature is + /// ANJHOOPIAKM(Int64, Boolean) (BBHENFCNLAB.txt:7503). Ignoring the + /// flag made un-cheering a no-op: the button toggled off in the UI and the + /// cheer stayed on the invention forever. + public sealed record CheerRequest(long InventionId, bool Cheer = true); + /// POST api/inventions/v1/cheer — answers with the + /// Status/Invention/InventionVersion envelope like every other invention + /// mutation: the 2023 method is FGLDKEJLAKB<BDNCJIPHHOK> + /// (OEGFNFEAAGO.txt:11065) and December's is + /// IPromise<AHEPPAEOLOD> (2020.12 BBHENFCNLAB.txt:7503). The old + /// {Id, CheerCount} reply left Invention null on 2023 and threw + /// KeyNotFoundException on December, so the tile never refreshed its + /// count. [HttpPost("api/inventions/v1/cheer")] [Authorize] public async Task Cheer([FromBody] CheerRequest req) @@ -692,10 +1081,11 @@ public class InventionsController( var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted); if (inv is null) return NotFound(); - // Idempotent: one cheer per (player, invention). + // Idempotent in both directions: one cheer row per (player, invention). var existing = await db.Cheers.FirstOrDefaultAsync(c => c.FromPlayerId == pid && c.TargetInventionId == req.InventionId); - if (existing is null) + + if (req.Cheer && existing is null) { db.Cheers.Add(new CheerEntity { @@ -711,12 +1101,29 @@ public class InventionsController( PushNotificationId.InventionModerationStateChanged, new { Type = "cheer", inv.Id, From = pid, inv.CheerCount }); } - return Ok(new { inv.Id, inv.CheerCount }); + else if (!req.Cheer && existing is not null) + { + db.Cheers.Remove(existing); + inv.CheerCount = Math.Max(0, inv.CheerCount - 1); + await db.SaveChangesAsync(); + } + + return Ok(await EnvelopeAsync(inv)); } public sealed record ReportInventionRequest( long InventionId, int? ReportCategory, string? Details); + /// POST api/inventions/v1/report — the reply is a + /// success/message pair, not a status flag of our own invention. The 2023 + /// method is FGLDKEJLAKB<PHMHCPEMABG> (OEGFNFEAAGO.txt:10917) + /// and PHMHCPEMABG holds a Boolean + a String (PHMHCPEMABG.txt:3/23) read + /// from the literals "Success"/"Message" (GBPDOLJBABB.txt:191/210); the + /// 2020.12 watch's KLAMKCBENEA.PPGFHEDFBEA pulls the same two literals + /// (2020.12 KLAMKCBENEA.txt:85/90). Our old {Reported:true} left + /// Success at default false, so the report UI reported failure even though + /// the row persisted. Message must be a string — December's + /// Util.GetKey casts it — so send "" rather than null. [HttpPost("api/inventions/v1/report")] [Authorize] public async Task Report([FromBody] ReportInventionRequest req) @@ -734,9 +1141,225 @@ public class InventionsController( Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)], }); await db.SaveChangesAsync(); - return Ok(new { Reported = true }); + return Ok(new { Success = true, Message = string.Empty }); } + // ── Rec Room Studio remote run ─────────────────────────────────────── + + /// One uploaded blob as the client describes it: three strings. + /// Wire keys are literal in the generated serializer — "Filename", "Hash", + /// "OwnershipProof" (FLELPHJDLNG.txt:255/274/290, with camelCase and + /// all-lowercase aliases registered alongside each). The backing type is + /// CFPEPOJAMHH/IFIBBJDIHJH, three System.String accessors + /// (CFPEPOJAMHH_NestedType_IFIBBJDIHJH.txt:3/25/47). Same triple the room + /// save commit sends on rooms/{id}/subrooms/{id}/data; declared here + /// so this controller binds its own body. + public sealed class StudioBlobRef + { + public string? Filename { get; set; } + public string? Hash { get; set; } + public string? OwnershipProof { get; set; } + } + + /// Body of remote-run/push-to-studio — RecNet type + /// FNAGBPCAGJD. Field order/types come from the request builder + /// (EHIOLHBGODG.txt:045-070 writes +16 String, +24 Int64, +32 Int64, + /// +40 String, +48/+56 the two blob refs, +64 a Nullable<Int32>) + /// and the property accessors on FNAGBPCAGJD.txt:3-141. The JSON key for + /// each is literal in the generated serializer OFMNNCMPEPA.txt:535-698: + /// SessionId, RoomId, SubRoomId, UnityAssetId, RoomData, SubRoomData, + /// SavedByAccountId (camel/lower aliases registered too). + public sealed class PushToStudioRequest + { + public string? SessionId { get; set; } + public long RoomId { get; set; } + public long SubRoomId { get; set; } + public string? UnityAssetId { get; set; } + public StudioBlobRef? RoomData { get; set; } + public StudioBlobRef? SubRoomData { get; set; } + public int? SavedByAccountId { get; set; } + } + + /// POST remote-run/push-to-studio — hands the room the + /// player just uploaded to the paired Rec Room Studio session, WITHOUT + /// committing it as the room's live save (that is the separate + /// rooms/{id}/subrooms/{id}/data path; the client's two flows are + /// sibling methods OGPDOMCNIFM.txt:271 vs :449). + /// + /// Verb. POST. The request builder at EHIOLHBGODG.txt:411-417 + /// moves the route literal "remote-run/push-to-studio" into r9 and + /// 2 (BestHTTP HTTPMethods.Post) into rdx before + /// BNDIAONDFFF..ctor(verb, host, route). No cmov — one verb only. + /// The body is RAW JSON, not form fields: :433 hands the serialized DTO to + /// BNDIAONDFFF.FJLLPHFOOJJ (RawJsonForm, application/json), + /// so this binds via — the + /// explicit [ModelBinder] is required or [ApiController] re-infers + /// [FromBody] and 415s a form-shaped caller. + /// + /// Response. FGLDKEJLAKB<CEELGOLBHIL> + /// (EHIOLHBGODG.txt:217) — a single RemoteRunDetails object, EIGHT keys, + /// all literal in its generated reader PPHLKNGCGOE.txt:599-786: + /// SessionId (String), RoomId and SubRoomId + /// (Nullable<Int64> — CEELGOLBHIL.txt:25/51), then + /// UnityAssetId, RoomDataFilename, RoomDataHash, SubRoomDataFilename, + /// SubRoomDataHash (String). Note the response FLATTENS the two blob + /// refs the request nests, and drops OwnershipProof. A failed/malformed + /// reply surfaces the client's "Failed to push to Rec Room Studio" toast + /// (EHIOLHBGODG.txt:445). + /// + /// What it persists. The pushed blobs were already stored by + /// /upload (every FileType there inserts a + /// ), so the push is a registration step: + /// it stamps the sub-room save blob with the room + sub-room it belongs to + /// — that is what makes it appear in the per-sub-room + /// datahistory/"Restore to old version" list, which the FileType=6 + /// room-metadata upload path (RoomId=0, SubRoomId=null) cannot do on its + /// own — writes the session id into + /// (the column that exists for + /// exactly this and is echoed in room details), records the pushed Unity + /// asset id on the sub-room's , and keeps the + /// whole push under remoterun:{SessionId} in + /// so the details survive for the paired + /// Studio session to resolve. + /// is deliberately NOT flipped: it changes in-room MakerPen UI and the + /// client has no unlink call, so a push must not one-way-toggle it. + /// + /// Not implemented: live relay of the details to a second + /// logged-in session. The client's receive side + /// (EHIOLHBGODG.MCPBJLHFOBC(CEELGOLBHIL) → the + /// Action<CEELGOLBHIL> that MBOJJFBIAGE.txt:366/978 subscribes) + /// has no call site anywhere in the IsilDump, so the notification id that + /// carries it cannot be established from the binary — inventing one would + /// be a guess. The HTTP round-trip is complete and the push is durable + /// either way. + [HttpPost("remote-run/push-to-studio")] + [HttpPost("roomserver/remote-run/push-to-studio")] + [Authorize] + public async Task PushToStudio( + [ModelBinder(typeof(Binding.FormOrJsonModelBinder))] PushToStudioRequest req) + { + var pid = CurrentPlayerId; + + var sessionId = Clamp((req.SessionId ?? string.Empty).Trim(), 100); + if (sessionId.Length == 0) + return BadRequest(new { Error = "missing SessionId" }); + + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId); + if (room is null) return NotFound(); + + // Same gate the room-save upload uses (StorageController.UploadRoomSave): + // creator, admin, or an accepted CoOwner (Role 0). + var canPush = room.CreatorPlayerId == pid + || await db.Players.AnyAsync(p => p.Id == pid && p.IsAdmin) + || await db.RoomRoles.AnyAsync(r => + r.RoomId == room.Id && r.PlayerId == pid && r.Accepted && r.Role == 0); + if (!canPush) return Forbid(); + + var subRoomId = req.SubRoomId; + var scene = await db.RoomScenes + .FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId); + + var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128); + if (subRoomDataFilename.Length == 0) + return BadRequest(new { Error = "missing SubRoomData.Filename" }); + + // The push names blobs the client uploaded moments ago; if the row is + // absent the bytes are not on this server and Studio could never fetch + // them. Fail loudly rather than answering with a filename that 404s. + var subRoomBlob = await db.RoomDataBlobs + .FirstOrDefaultAsync(b => b.BlobName == subRoomDataFilename); + if (subRoomBlob is null) + return BadRequest(new { Error = "unknown SubRoomData.Filename" }); + if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id; + subRoomBlob.SubRoomId ??= subRoomId; + + var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128); + if (roomDataFilename.Length > 0) + { + // FileType=6 (RoomMetadata) uploads land with RoomId=0 — the push + // is the first point at which the owning room is known. + var roomBlob = await db.RoomDataBlobs + .FirstOrDefaultAsync(b => b.BlobName == roomDataFilename); + if (roomBlob is null) + return BadRequest(new { Error = "unknown RoomData.Filename" }); + if (roomBlob.RoomId == 0) roomBlob.RoomId = room.Id; + } + + var unityAssetId = Clamp((req.UnityAssetId ?? string.Empty).Trim(), 64); + if (unityAssetId.Length == 0) + { + unityAssetId = scene?.StudioUnityAssetId ?? string.Empty; + } + else if (scene is not null && scene.StudioUnityAssetId != unityAssetId) + { + scene.StudioUnityAssetId = unityAssetId; + scene.DataModifiedAt = DateTime.UtcNow; + } + + var roomDataHash = Clamp((req.RoomData?.Hash ?? string.Empty).Trim(), 128); + var subRoomDataHash = Clamp((req.SubRoomData?.Hash ?? string.Empty).Trim(), 128); + + room.StudioSessionId = sessionId; + + var record = System.Text.Json.JsonSerializer.Serialize(new + { + RoomId = room.Id, + SubRoomId = subRoomId, + UnityAssetId = unityAssetId, + RoomDataFilename = roomDataFilename, + RoomDataHash = roomDataHash, + SubRoomDataFilename = subRoomDataFilename, + SubRoomDataHash = subRoomDataHash, + SavedByAccountId = req.SavedByAccountId ?? (int)pid, + PushedAt = DateTime.UtcNow, + }); + if (record.Length > 1024) + { + // PlayerSettingEntity.Value is varchar(1024); truncating JSON would + // store an unparseable fragment, so drop the optional hashes first. + record = System.Text.Json.JsonSerializer.Serialize(new + { + RoomId = room.Id, + SubRoomId = subRoomId, + UnityAssetId = unityAssetId, + RoomDataFilename = roomDataFilename, + SubRoomDataFilename = subRoomDataFilename, + SavedByAccountId = req.SavedByAccountId ?? (int)pid, + PushedAt = DateTime.UtcNow, + }); + } + + var key = $"remoterun:{sessionId}"; + var setting = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == pid && s.Key == key); + if (setting is null) + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = pid, Key = key, Value = record, + }); + else + setting.Value = record; + + await db.SaveChangesAsync(); + + return Ok(new + { + SessionId = sessionId, + RoomId = (long?)room.Id, + SubRoomId = (long?)subRoomId, + UnityAssetId = unityAssetId, + RoomDataFilename = roomDataFilename, + RoomDataHash = roomDataHash, + SubRoomDataFilename = subRoomDataFilename, + SubRoomDataHash = subRoomDataHash, + }); + } + + /// Trim a wire string to a column's length. Every string that + /// reaches a MaxLength column here comes straight off the request. + private static string Clamp(string value, int max) + => value.Length <= max ? value : value[..max]; + // ── Wire serializers ───────────────────────────────────────────────── private static int ClampInventionPermission(int v) => v switch @@ -785,13 +1408,40 @@ public class InventionsController( i.CreatorPermission, i.GeneralPermission, IsAGInvention = i.IsAgInvention, + // The 2023-03-21 DTO dropped IsPublished and reads "Accessibility" + // instead (IOEPPCKGBFL.txt:309 / :1602 — the member switch has no + // IsPublished arm at all). Constants come from the nested enum + // AEFFFPIJDHG.GAKJKOGJEEH in the 2023.06 dump + // (dump.cs:1282644-1282646): Private=0, Public=1, Unlisted=2. With the + // key absent the reader left the field at its default, so EVERY + // published store invention presented as Private in the 2023 UI. + // InventionEntity has no Accessibility column, so only the + // Private/Public bit round-trips; Unlisted needs a real column. + // IsPublished stays on the wire because the 2020.12 watch's + // OBBBPCBIMME reader still requires it. + Accessibility = i.IsPublished ? 1 : 0, + // IOEPPCKGBFL.txt:565 — DorkNet runs no certification programme. + IsCertifiedInvention = false, i.Price, HideFromPlayer = false, }; private static object ToWireV4(InventionEntity i) => ToWire(i); - private static object ToVersionWire(InventionVersionEntity v) => new + private static object ToVersionWire(InventionVersionEntity v) + => ToVersionWire(v, 0, 0); + + /// The 2023 InventionVersion DTO reads NINE keys + /// (OLPHKLCPFEF.txt:084-295): the six we already emitted plus + /// ChipsCost, CloudVariablesCost and BlobHash. + /// has no column for any of the three, + /// so stored rows report 0/empty and only the save/addversion round-trip can + /// echo back the costs the client just sent. An empty BlobHash is safe: + /// PLIKEBBPJGI's hash getter (EHENFDMIAIM, OLPHKLCPFEF's owner type + /// PLIKEBBPJGI.txt:167) has no call site anywhere in the IsilDump — the + /// client only ever writes it — so the key's presence is what matters. + private static object ToVersionWire( + InventionVersionEntity v, int chipsCost, int cloudVariablesCost) => new { v.InventionId, ReplicationId = string.IsNullOrEmpty(v.ReplicationId) @@ -799,6 +1449,34 @@ public class InventionsController( v.VersionNumber, v.InstantiationCost, v.LightsCost, + ChipsCost = chipsCost, + CloudVariablesCost = cloudVariablesCost, v.BlobName, + BlobHash = string.Empty, }; + + /// Every invention MUTATION route answers with the + /// Status/Invention/InventionVersion envelope, never a bare invention: the + /// 2023 client deserialises BDNCJIPHHOK (key literals at + /// GMBGBPNMGBA.txt:044-095) and the 2020.12 watch deserialises + /// AHEPPAEOLOD, whose reader pulls the same three literals + /// (2020.12 IsilDump AHEPPAEOLOD.txt:110/115/120). Returning the invention + /// flat left Invention null on 2023 and threw KeyNotFoundException on + /// December. Status = 0 is success — + /// EnterInventionNameDialog.txt:622-624 loads the boxed Status field and + /// does test eax,eax / je <success>. + private async Task EnvelopeAsync(InventionEntity inv) + { + var current = await db.InventionVersions + .Where(x => x.InventionId == inv.Id) + .OrderByDescending(x => x.VersionNumber) + .FirstOrDefaultAsync(); + object? versionWire = current is null ? null : ToVersionWire(current); + return new + { + Status = 0, + Invention = ToWire(inv), + InventionVersion = versionWire, + }; + } } diff --git a/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs b/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs index 5f3fb68..2b63e37 100644 --- a/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs +++ b/DorkNet.Server/Controllers/API/Inventory/InventoryController.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -78,8 +79,85 @@ public class InventoryController( /// POST api/equipment/v1/update — toggle the /// "favorited" flag on a piece of equipment (the watch lets the /// player pin commonly-used tools). + /// The 2023-03-21 client sends a JSON array of equipment + /// updates here, not a single object (it serialises a + /// List<BCINJINBBHG>). Binding [FromBody] onto one + /// object failed model binding and returned an automatic 400, so equip and + /// favourite changes never persisted for that build. The body is now read + /// leniently and both shapes are applied through the same per-item path. + /// [HttpPost("api/equipment/v1/update")] - public async Task UpdateEquipment([FromBody] EquipmentUpdateRequest req) + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] + public async Task UpdateEquipment() + { + var requests = await ReadEquipmentUpdatesAsync(); + if (requests.Count == 0) return BadRequest("missing prefab/mod identifier"); + + IActionResult last = Ok(); + foreach (var req in requests) + { + last = await ApplyEquipmentUpdateAsync(req); + // Surface the first rejection rather than pretending the whole + // batch succeeded. + if (last is not OkObjectResult and not OkResult) return last; + } + return last; + } + + /// Accepts a single object, an array of objects, or a form post. + private async Task> ReadEquipmentUpdatesAsync() + { + var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var result = new List(); + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + string? S(params string[] keys) + { + foreach (var k in keys) + if (form.TryGetValue(k, out var v) && !string.IsNullOrEmpty(v)) return v.ToString(); + return null; + } + bool? B(params string[] keys) + => bool.TryParse(S(keys), out var b) ? b : null; + result.Add(new EquipmentUpdateRequest + { + PrefabName = S("PrefabName", "prefabName"), + ModificationGuid = S("ModificationGuid", "modificationGuid"), + Favorited = B("Favorited", "favorited") ?? false, + Equipped = B("Equipped", "equipped"), + IsEquipped = B("IsEquipped", "isEquipped"), + }); + return result; + } + + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await JsonDocument.ParseAsync(Request.Body); + Request.Body.Position = 0; + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Array) + { + foreach (var el in root.EnumerateArray()) + { + var one = el.Deserialize(opts); + if (one is not null) result.Add(one); + } + } + else if (root.ValueKind == JsonValueKind.Object) + { + var one = root.Deserialize(opts); + if (one is not null) result.Add(one); + } + } + catch (JsonException) { /* unparseable body → treated as empty */ } + return result; + } + + private async Task ApplyEquipmentUpdateAsync(EquipmentUpdateRequest req) { var pid = Me; // Catalog-backed skins use a compound slug so we can round-trip @@ -209,16 +287,28 @@ public class InventoryController( return Ok(new { row.Id, Count = row.Quantity }); } + /// The 2023-03-21 client serialises a TransferConsumableRequest + /// with JsonUtility.ToJson and posts it as a raw JSON body. With + /// [ApiController] plus [FromForm] parameters, an + /// application/json request bound nothing and the transfer was + /// rejected as invalid, so consumable gifting never worked on that build. + /// The JSON body is now read alongside the original form/query paths. [HttpPost("api/consumables/v1/transfer")] + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] public async Task TransferConsumable( [FromForm] long? id, [FromForm] long? recipientPlayerId, [FromForm] int quantity = 1) { var pid = Me; - var inventoryId = id ?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0); - var recipient = recipientPlayerId ?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0); - var amount = Math.Max(1, quantity); + var body = await ReadTransferBodyAsync(); + var inventoryId = id + ?? body.Id + ?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0); + var recipient = recipientPlayerId + ?? body.RecipientPlayerId + ?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0); + var amount = Math.Max(1, body.Quantity ?? quantity); if (inventoryId <= 0 || recipient <= 0 || recipient == pid) return BadRequest("invalid_transfer"); @@ -247,6 +337,69 @@ public class InventoryController( return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity }); } + private readonly record struct TransferBody(long? Id, long? RecipientPlayerId, int? Quantity); + + /// Reads the 2023 client's raw JSON transfer body. JsonUtility + /// emits field names verbatim, and the exact casing of that DTO is not + /// recoverable from the ISIL, so both conventions are accepted. + private async Task ReadTransferBodyAsync() + { + if (Request.HasFormContentType) return default; + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await JsonDocument.ParseAsync(Request.Body); + Request.Body.Position = 0; + if (doc.RootElement.ValueKind != JsonValueKind.Object) return default; + + long? L(params string[] keys) + { + foreach (var k in keys) + if (doc.RootElement.TryGetProperty(k, out var e) && + e.ValueKind == JsonValueKind.Number && e.TryGetInt64(out var v)) return v; + return null; + } + int? I(params string[] keys) + { + foreach (var k in keys) + if (doc.RootElement.TryGetProperty(k, out var e) && + e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v)) return v; + return null; + } + return new TransferBody( + L("Id", "id", "InventoryId", "inventoryId"), + L("RecipientPlayerId", "recipientPlayerId"), + I("Quantity", "quantity", "Count", "count")); + } + catch (JsonException) { return default; } + } + + /// GET api/consumables/v1/getTransferable/{playerId} — the + /// consumables the caller may gift to playerId. No handler existed, + /// so the transfer UI 404'd and could never list anything to send. + /// Returns the same grouped shape as v2/getUnlocked. + [HttpGet("api/consumables/v1/getTransferable/{playerId:long}")] + public async Task GetTransferableConsumables(long playerId) + { + var pid = Me; + var rows = await db.PlayerInventory + .Where(p => p.PlayerId == pid && p.Quantity > 0) + .ToListAsync(); + + // Can't gift to yourself; everything else the caller holds is fair game. + var transferable = playerId == pid ? [] : rows; + return Ok(transferable + .GroupBy(r => r.ItemSlug) + .Select(g => new + { + Consumable = g.Key, + Count = g.Sum(r => r.Quantity), + Id = g.Min(r => r.Id), + }) + .ToList()); + } + public sealed class ActivateConsumableRequest { public long Id { get; set; } diff --git a/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs index 73ae682..6248f2b 100644 --- a/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs +++ b/DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.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; using System.Text.Json; @@ -18,27 +19,75 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con { private long Me => this.RequireCurrentPlayerId(); + /// Marker category for collection rows; never surfaced as a keepsake instance. + private const string CollectionCategory = "collection"; + + /// + /// Mirrors KeepsakeGlobalConfigDTO.SocialXpBoostEnabled below. The collect handler has + /// to agree with what globalconfig advertises, so the flag lives in one place. + /// + private const bool SocialXpBoostEnabled = false; + + private const int KeepsakeRoomLimit = 64; + [HttpGet] public async Task Mine() { await EnsureLoginKeepsakeAsync(Me); var rows = await db.Keepsakes - .Where(k => k.PlayerId == Me) + .Where(k => k.PlayerId == Me && k.Category != CollectionCategory) .OrderByDescending(k => k.EarnedAt) .ToListAsync(); return Ok(rows.Select(ToWire)); } + // KeepsakeCategoryConfigDTO = {KeepsakeCategoryId(enum), VisualId, LimitPerRoom, XpValue, + // IconOutlineImageName, IconFilledImageName} - dump.cs:1234842-1234878; the accepted wire key + // names are enumerated by its Utf8Json formatter (IsilDump/RecNet.Runtime/HIKOJENMDKE.txt:66-160 + // - "KeepsakeCategoryId"/"VisualId"/"LimitPerRoom"/"XpValue" plus camel/lower aliases). + // KeepsakeCategoryId is the PIHCLHIKEPH enum (dump.cs:1199524: Explore=0, GreenPowerCore=1, + // Present=2, PurplePowerCore=3, UnnamedKeepsakeNumber1..5 = 4..8), and the client folds this + // list into a Dictionary (NCCLEJPIABA.DHMFBMMAGCL, + // NCCLEJPIABA.txt:4204) that every placed instance is looked up in. The previous three-entry + // list keyed by our internal account/event/room buckets covered ids 0-2 only, so six of the + // nine placeable categories had no config - and, since collect prices the reward from XpValue, + // no reward either. + private static readonly (int Id, string VisualId)[] CategoryConfigs = + [ + (0, "Explore"), + (1, "GreenPowerCore"), + (2, "Present"), + (3, "PurplePowerCore"), + (4, "UnnamedKeepsakeNumber1"), + (5, "UnnamedKeepsakeNumber2"), + (6, "UnnamedKeepsakeNumber3"), + (7, "UnnamedKeepsakeNumber4"), + (8, "UnnamedKeepsakeNumber5"), + ]; + + /// + /// XP a single keepsake collection is worth. This is the one number both + /// api/keepsakes/categories (as XpValue) and + /// api/keepsakes/{id}/collect (as TotalXp) are derived from, so the toast the + /// client renders after a collect matches the config it already downloaded. + /// + private const int KeepsakeXpValue = 25; + [HttpGet("categories")] [AllowAnonymous] public IActionResult Categories() { - var results = new[] - { - new { KeepsakeCategoryId = 0, VisualId = "account", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty }, - new { KeepsakeCategoryId = 1, VisualId = "event", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty }, - new { KeepsakeCategoryId = 2, VisualId = "room", LimitPerRoom = 64, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty }, - }; + var results = CategoryConfigs + .Select(c => new + { + KeepsakeCategoryId = c.Id, + c.VisualId, + LimitPerRoom = KeepsakeRoomLimit, + XpValue = KeepsakeXpValue, + IconOutlineImageName = string.Empty, + IconFilledImageName = string.Empty, + }) + .ToArray(); return Ok(new { Results = results, @@ -59,10 +108,11 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con [HttpGet("events/{eventId:long}")] public async Task EventInstances(long eventId) { - var eventKey = eventId.ToString(System.Globalization.CultureInfo.InvariantCulture); + var eventKey = eventId.ToString(CultureInfo.InvariantCulture); + // NOT filtered by PlayerId: Instances is the whole event's placement set (the hunt map), + // CollectionRecords is only what the caller has picked up. var rows = await db.Keepsakes - .Where(k => k.PlayerId == Me - && k.Category == "event" + .Where(k => k.Category == "event" && (k.EventKey == eventKey || k.EventKey == $"event:{eventKey}" || k.EventKey == $"event/{eventKey}" @@ -71,12 +121,34 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con .OrderByDescending(k => k.EarnedAt) .ToListAsync(); + // RecNet.KeepsakeProgressionEventInstancesDTO has exactly two members, both + // List (= {Int64, List}) - dump.cs:1234798-1234810, + // ctor(List, List) in + // IsilDump/RecNet.Runtime/RecNet/KeepsakeProgressionEventInstancesDTO.txt:14. + // Emitting the flat keepsake rows here left RoomId=0 / KeepsakeInstanceIds=null per entry. + // Key names follow the same RecNet naming the room endpoint below already uses; the literal + // JSON names live in Newtonsoft attribute metadata the dumps do not render, so they are + // unverified - Json.NET matching is case-insensitive and ignores unknown members. + // + // CollectionRecords is what the CALLER has picked up, which is a different set from what the + // caller PLACED: it is fed by the collect handler below (Category=CollectionCategory rows). + var eventInstanceIds = rows + .Select(r => TryParseInstanceKey(r.EventKey, out _, out _, out _, out var id) ? id : StableKeepsakeInstanceId(r.Id)) + .ToHashSet(); + var collected = (await LoadMyCollectionsAsync()) + .Where(c => eventInstanceIds.Contains(c.InstanceId)) + .GroupBy(c => c.RoomId) + .Select(group => (object)new + { + RoomId = group.Key, + KeepsakeInstanceIds = group.Select(c => c.InstanceId).Distinct().Select(id => id.ToString("D")).ToList(), + }) + .ToList(); + return Ok(new { - KeepsakeProgressionEventId = eventId, - Instances = rows.Select(ToWire), - CollectionRecords = Array.Empty(), - KeepsakeProgressionEventIds = new[] { eventId }, + Instances = GroupInstanceIdsByRoom(rows), + CollectionRecords = collected, }); } @@ -97,27 +169,167 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con .OrderByDescending(k => k.EarnedAt) .ToListAsync(); var instances = rows.Select(row => ToRoomInstanceWire(row, roomId)).ToList(); + + // KeepsakeCollectionRecordDTO = {Int32 AccountId, Guid KeepsakeInstanceId, DateTime + // CollectedAt} - dump.cs:1234642-1234666, wire keys confirmed by its Utf8Json formatter + // (IsilDump/RecNet.Runtime/CNNAFLNKDCL.txt:48,75,99). Scoped to the caller: the DTO carries + // AccountId so the client can filter, but returning only the caller's rows is correct under + // either reading and keeps other players' pickups out of the payload. + var collectPrefix = $"collect:{roomIdText}:"; + var me = Me; + var collectionRecords = await db.Keepsakes + .Where(k => k.PlayerId == me + && k.Category == CollectionCategory + && EF.Functions.Like(k.EventKey, collectPrefix + "%")) + .ToListAsync(); + return Ok(new { Instances = instances, - CollectionRecords = Array.Empty(), + CollectionRecords = collectionRecords + .Where(k => TryParseCollectionKey(k.EventKey, out _, out _)) + .Select(k => + { + TryParseCollectionKey(k.EventKey, out _, out var instanceId); + return (object)new + { + AccountId = k.PlayerId is > 0 and <= int.MaxValue ? (int)k.PlayerId : 0, + KeepsakeInstanceId = instanceId.ToString("D"), + CollectedAt = k.EarnedAt, + }; + }) + .ToList(), KeepsakeProgressionEventIds = Array.Empty(), }); } + /// + /// Removes a placed keepsake instance. The 2023 client issues + /// DELETE api/keepsakes/{guid} - NCCLEJPIABA.JBKENCNIEPA(System.Guid) builds + /// String.Format("{0}/{1}", "api/keepsakes", guid) and passes verb 4 (DELETE) to the + /// BNDIAONDFFF ctor (NCCLEJPIABA.txt:1544, 1687-1698 - Move rdx, 4). Its return type is + /// the non-generic LDGADANDBIO promise, so no response body is deserialised; a bare 200 + /// is enough. Client-side the tool refuses unless the caller is at least a room co-owner + /// ("Must be at least a room co-owner to modify keepsakes.", + /// RecRoom.Keepsakes.Runtime/PDFJLLECNBE.txt:3363), which we re-check server-side. + /// + [HttpDelete("{keepsakeInstanceId:guid}")] + public async Task Delete(Guid keepsakeInstanceId) + { + var instance = await FindInstanceAsync(keepsakeInstanceId); + if (instance is null) return NotFound(); + + var roomId = TryParseInstanceKey(instance.EventKey, out var parsedRoomId, out _, out _, out _) + ? parsedRoomId + : 0; + if (!await CanModifyInstanceAsync(instance, roomId, Me)) return Forbid(); + + // Drop the collection rows for this instance too, or they linger as records pointing at a + // keepsake nobody can see and keep suppressing a future re-placement's collect. + var suffix = keepsakeInstanceId.ToString("N"); + var orphanedCollections = await db.Keepsakes + .Where(k => k.Category == CollectionCategory && EF.Functions.Like(k.EventKey, $"%:{suffix}")) + .ToListAsync(); + + db.Keepsakes.Remove(instance); + db.Keepsakes.RemoveRange(orphanedCollections + .Where(k => TryParseCollectionKey(k.EventKey, out _, out var id) && id == keepsakeInstanceId)); + await db.SaveChangesAsync(); + return Ok(); + } + + /// + /// Records that the caller picked up a placed keepsake and reports the XP it was worth. The + /// 2023 client issues POST api/keepsakes/{guid}/collect - + /// NCCLEJPIABA.HFGKFAHFELM(System.Guid) formats "{0}/{1}/collect" over + /// "api/keepsakes" and passes verb 2 (POST) (NCCLEJPIABA.txt:1738, 1963-1974 - + /// Move rdx, 2), with no request body. The response type is + /// FGLDKEJLAKB<DHNBKMHDANK>; DHNBKMHDANK is a two-Int32 object whose Utf8Json + /// formatter writes "TotalXp" and "SocialBoostXp" (RecNet.Runtime/PKCMBJFBHBO.txt:42,69,100,121 + /// - reader also accepts totalXp/socialBoostXp). The consumer computes + /// TotalXp - SocialBoostXp for the base figure and shows the boost separately + /// (PDFJLLECNBE_NestedType_LKIPMJFEAFK.txt:82-99), so TotalXp is the whole award, boost + /// included - not a running account total. + /// + [HttpPost("{keepsakeInstanceId:guid}/collect")] + public async Task Collect(Guid keepsakeInstanceId) + { + var instance = await FindInstanceAsync(keepsakeInstanceId); + if (instance is null) return NotFound(); + + var parsed = TryParseInstanceKey(instance.EventKey, out var roomId, out _, out var categoryConfigId, out _); + if (!parsed) + { + roomId = 0; + categoryConfigId = CategoryId(instance.Category); + } + + var me = Me; + var collectKey = BuildCollectionKey(roomId, keepsakeInstanceId); + var alreadyCollected = await db.Keepsakes + .AnyAsync(k => k.PlayerId == me && k.Category == CollectionCategory && k.EventKey == collectKey); + + // Re-collecting is a no-op award rather than an error: the client only ever renders the two + // numbers, and paying out twice for one instance would be a fabricated reward. + if (alreadyCollected) return Ok(new { TotalXp = 0, SocialBoostXp = 0 }); + + db.Keepsakes.Add(new KeepsakeEntity + { + PlayerId = me, + Category = CollectionCategory, + EventKey = collectKey, + EarnedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + + return Ok(new + { + TotalXp = XpValueFor(categoryConfigId), + // SocialBoostXp is the boost slice *of* TotalXp - the client renders + // TotalXp - SocialBoostXp as the base figure - and globalconfig above advertises + // SocialXpBoostEnabled=false, so there is no slice and the whole award is base XP. + SocialBoostXp = 0, + }); + } + [HttpGet("globalconfig")] [AllowAnonymous] public IActionResult GlobalConfig() => Ok(new { KeepsakeFeatureEnabled = true, - KeepsakeRoomLimit = 64, - SocialXpBoostEnabled = false, + KeepsakeRoomLimit, + SocialXpBoostEnabled, }); [HttpPost] public async Task Create() { var req = await ReadRequestAsync(); + + // 2023 client: FGLDKEJLAKB OPPHCOJHEJI(HMECHOKOCBB category) + // (NCCLEJPIABA.txt:1146) POSTs a RecNet.AddKeepsakeInstanceRequest + // {Int64 RoomId, Int64? SubRoomId, Int32 KeepsakeCategory} - dump.cs:1234509-1234525, + // field offsets 0x10/0x18/0x28 in AddKeepsakeInstanceRequest.txt - and deserialises the + // response as a BARE Guid, not an object. The request is refused client-side for offline + // rooms ("Cannot add keepsakes in offline rooms", NCCLEJPIABA.txt:1533), so RoomId is + // always a real room here; its presence is what selects this path over the legacy DTO. + if (req.RoomId is > 0) + { + var roomId = req.RoomId.Value; + if (!await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId)) return NotFound(); + + var instanceId = Guid.NewGuid(); + db.Keepsakes.Add(new KeepsakeEntity + { + PlayerId = Me, + Category = "room", + EventKey = BuildRoomInstanceKey(roomId, req.SubRoomId, req.KeepsakeCategory ?? 0, instanceId), + EarnedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + return Ok(instanceId); + } + var key = Trim(req.EventKey, 128); if (key.Length == 0) key = $"manual:{Guid.NewGuid():N}"; var category = Trim(req.Category, 64); @@ -167,6 +379,9 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con var form = await Request.ReadFormAsync(); return new KeepsakeRequest { + RoomId = long.TryParse(form["RoomId"].FirstOrDefault() ?? form["roomId"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formRoom) ? formRoom : null, + SubRoomId = long.TryParse(form["SubRoomId"].FirstOrDefault() ?? form["subRoomId"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formSubRoom) ? formSubRoom : null, + KeepsakeCategory = int.TryParse(form["KeepsakeCategory"].FirstOrDefault() ?? form["keepsakeCategory"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formCategory) ? formCategory : null, Category = form["category"].FirstOrDefault() ?? form["Category"].FirstOrDefault(), EventKey = form["eventKey"].FirstOrDefault() ?? form["EventKey"].FirstOrDefault(), Title = form["title"].FirstOrDefault() ?? form["Title"].FirstOrDefault(), @@ -176,12 +391,28 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con }; } + // The 2023 client sends a RAW JSON body, not a form: NCCLEJPIABA.txt:1458-1465 serialises the + // whole AddKeepsakeInstanceRequest in one call and hands the string to BNDIAONDFFF's body + // setter. Read it as a document rather than binding a DTO so the numeric KeepsakeCategory and + // the legacy string Category can share one wire slot, and so key-name aliases are cheap. try { - return await JsonSerializer.DeserializeAsync( - Request.Body, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) - ?? new KeepsakeRequest(); + using var doc = await JsonDocument.ParseAsync(Request.Body); + var root = doc.RootElement; + return new KeepsakeRequest + { + RoomId = ReadInt64(root, "RoomId"), + SubRoomId = ReadInt64(root, "SubRoomId"), + // "Category" is listed as an alias only for the numeric form; when it carries the + // legacy string ("event"/"room") ReadInt64 declines it and ReadString picks it up. + KeepsakeCategory = (int?)ReadInt64(root, "KeepsakeCategory", "KeepsakeCategoryConfigId", "Category"), + Category = ReadString(root, "Category"), + EventKey = ReadString(root, "EventKey"), + Title = ReadString(root, "Title"), + Description = ReadString(root, "Description"), + ImageName = ReadString(root, "ImageName"), + EarnedAt = DateTime.TryParse(ReadString(root, "EarnedAt"), out var earnedAt) ? earnedAt : null, + }; } catch (JsonException) { @@ -189,6 +420,39 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con } } + private static bool TryFindMember(JsonElement root, string[] names, out JsonElement value) + { + if (root.ValueKind == JsonValueKind.Object) + { + foreach (var prop in root.EnumerateObject()) + { + foreach (var name in names) + { + if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = prop.Value; + return true; + } + } + } + } + + value = default; + return false; + } + + private static long? ReadInt64(JsonElement root, params string[] names) + { + if (!TryFindMember(root, names, out var el)) return null; + if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var number)) return number; + if (el.ValueKind == JsonValueKind.String + && long.TryParse(el.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) return parsed; + return null; + } + + private static string? ReadString(JsonElement root, params string[] names) + => TryFindMember(root, names, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null; + private object ToWire(KeepsakeEntity row) => new { row.Id, @@ -207,16 +471,161 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con var placedByAccountId = row.PlayerId is > 0 and <= int.MaxValue ? (int)row.PlayerId : 0; + var parsed = TryParseInstanceKey(row.EventKey, out var keyRoomId, out var subRoomId, out var categoryConfigId, out var instanceId); return new { - KeepsakeInstanceId = StableKeepsakeInstanceId(row.Id).ToString("D"), - KeepsakeCategoryConfigId = CategoryId(row.Category), + KeepsakeInstanceId = (parsed ? instanceId : StableKeepsakeInstanceId(row.Id)).ToString("D"), + // KeepsakeCategory is the visual config id (enum PIHCLHIKEPH: Explore=0, GreenPowerCore=1, + // Present=2, PurplePowerCore=3, ... - dump.cs:1199524), NOT our account/event/room bucket, + // so replay what the client sent; CategoryId() is only the fallback for legacy rows. + KeepsakeCategoryConfigId = parsed ? categoryConfigId : CategoryId(row.Category), PlacedByAccountId = placedByAccountId, - RoomId = roomId, - SubRoomId = (long?)null, + RoomId = parsed ? keyRoomId : roomId, + SubRoomId = subRoomId, }; } + private static List GroupInstanceIdsByRoom(IEnumerable rows) => rows + .Select(row => TryParseInstanceKey(row.EventKey, out var roomId, out _, out _, out var instanceId) + ? (RoomId: roomId, InstanceId: instanceId) + : (RoomId: 0L, InstanceId: StableKeepsakeInstanceId(row.Id))) + .GroupBy(x => x.RoomId) + .Select(group => (object)new + { + RoomId = group.Key, + KeepsakeInstanceIds = group.Select(x => x.InstanceId).Distinct().Select(id => id.ToString("D")).ToList(), + }) + .ToList(); + + // KeepsakeEntity has no room/sub-room/instance-id columns, so a placed instance is encoded into + // the existing 128-char EventKey. Layouts: + // room:{roomId}:{subRoomId|-}:{categoryConfigId}:{instanceGuid:N} (client placements) + // event:{eventId}:{roomId}:{subRoomId|-}:{categoryConfigId}:{instanceGuid:N} + // Both stay inside the LIKE prefixes the room/event queries above already use. Legacy short keys + // ("room:{roomId}", "event:{eventId}", "first-login") fail to parse and fall back to the + // MD5-derived instance id, which is what those rows were already reported with. + private static string BuildRoomInstanceKey(long roomId, long? subRoomId, int categoryConfigId, Guid instanceId) + { + var room = roomId.ToString(CultureInfo.InvariantCulture); + var subRoom = subRoomId?.ToString(CultureInfo.InvariantCulture) ?? "-"; + var category = categoryConfigId.ToString(CultureInfo.InvariantCulture); + return $"room:{room}:{subRoom}:{category}:{instanceId:N}"; + } + + private static bool TryParseInstanceKey(string? eventKey, out long roomId, out long? subRoomId, out int categoryConfigId, out Guid instanceId) + { + roomId = 0; + subRoomId = null; + categoryConfigId = 0; + instanceId = Guid.Empty; + + var parts = (eventKey ?? string.Empty).Split(':'); + int i; + if (parts.Length == 5 && string.Equals(parts[0], "room", StringComparison.OrdinalIgnoreCase)) i = 1; + else if (parts.Length == 6 && string.Equals(parts[0], "event", StringComparison.OrdinalIgnoreCase)) i = 2; + else return false; + + if (!Guid.TryParseExact(parts[i + 3], "N", out instanceId)) return false; + if (!long.TryParse(parts[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)) return false; + if (long.TryParse(parts[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var sub)) subRoomId = sub; + if (!int.TryParse(parts[i + 2], NumberStyles.Integer, CultureInfo.InvariantCulture, out categoryConfigId)) categoryConfigId = 0; + return true; + } + + // A collection record is stored as its own keepsake row so no schema change is needed: + // Category = "collection", EventKey = "collect:{roomId}:{instanceGuid:N}" + // The room id is baked in so the room endpoint can filter by prefix and the event endpoint can + // group by room without re-reading the instance rows. "collect:" shares no prefix with the + // "room:"/"event:" instance keys, so collection rows never leak into an instance query, and + // Mine() filters the category out of the legacy keepsake list. + private static string BuildCollectionKey(long roomId, Guid instanceId) + => $"collect:{roomId.ToString(CultureInfo.InvariantCulture)}:{instanceId:N}"; + + private static bool TryParseCollectionKey(string? eventKey, out long roomId, out Guid instanceId) + { + roomId = 0; + instanceId = Guid.Empty; + var parts = (eventKey ?? string.Empty).Split(':'); + if (parts.Length != 3 || !string.Equals(parts[0], "collect", StringComparison.OrdinalIgnoreCase)) return false; + if (!long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)) return false; + return Guid.TryParseExact(parts[2], "N", out instanceId); + } + + private async Task> LoadMyCollectionsAsync() + { + var me = Me; + var rows = await db.Keepsakes + .Where(k => k.PlayerId == me && k.Category == CollectionCategory) + .Select(k => k.EventKey) + .ToListAsync(); + var results = new List<(long RoomId, Guid InstanceId)>(rows.Count); + foreach (var key in rows) + { + if (TryParseCollectionKey(key, out var roomId, out var instanceId)) + results.Add((roomId, instanceId)); + } + + return results; + } + + /// + /// Resolves the guid the client holds (from api/keepsakes/rooms/{roomId}, or the guid + /// this server handed back from POST api/keepsakes) to the row that encodes it. + /// + private async Task FindInstanceAsync(Guid keepsakeInstanceId) + { + var suffix = keepsakeInstanceId.ToString("N"); + var candidates = await db.Keepsakes + .Where(k => (k.Category == "room" || k.Category == "event") + && EF.Functions.Like(k.EventKey, $"%:{suffix}")) + .ToListAsync(); + var match = candidates.FirstOrDefault(k => + TryParseInstanceKey(k.EventKey, out _, out _, out _, out var id) && id == keepsakeInstanceId); + if (match is not null) return match; + + // Rows written before the instance id was encoded into EventKey are reported to the client + // with the MD5-derived StableKeepsakeInstanceId, which no LIKE can match. Resolve those by + // id only - the projection keeps it to one column over the short-key rows. + var legacyIds = await db.Keepsakes + .Where(k => (k.Category == "room" || k.Category == "event") + && !EF.Functions.Like(k.EventKey, "%:%:%:%")) + .Select(k => k.Id) + .ToListAsync(); + var legacyId = legacyIds.FirstOrDefault(id => StableKeepsakeInstanceId(id) == keepsakeInstanceId); + return legacyId == 0 ? null : await db.Keepsakes.FirstOrDefaultAsync(k => k.Id == legacyId); + } + + /// + /// Placement and removal are gated client-side on room co-ownership + /// ("Must be at least a room co-owner to modify keepsakes.", PDFJLLECNBE.txt:3363); the placer + /// and server admins are allowed through as well. + /// + private async Task CanModifyInstanceAsync(KeepsakeEntity instance, long roomId, long playerId) + { + if (instance.PlayerId == playerId) return true; + + if (roomId > 0) + { + var creatorId = await db.Rooms.AsNoTracking() + .Where(r => r.Id == roomId) + .Select(r => (long?)r.CreatorPlayerId) + .FirstOrDefaultAsync(); + if (creatorId == playerId) return true; + + var isCoOwner = await db.RoomRoles.AnyAsync(r => + r.RoomId == roomId && r.PlayerId == playerId && r.Role == 0 && r.Accepted); + if (isCoOwner) return true; + } + + return await db.Players + .Where(p => p.Id == playerId) + .Select(p => p.IsAdmin) + .FirstOrDefaultAsync(); + } + + private static int XpValueFor(int keepsakeCategoryConfigId) + => CategoryConfigs.Any(c => c.Id == keepsakeCategoryConfigId) ? KeepsakeXpValue : 0; + private static int CategoryId(string? category) => string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 : string.Equals(category, "room", StringComparison.OrdinalIgnoreCase) ? 2 : @@ -236,6 +645,11 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con public sealed class KeepsakeRequest { + // RecNet.AddKeepsakeInstanceRequest (2023 client). + public long? RoomId { get; set; } + public long? SubRoomId { get; set; } + public int? KeepsakeCategory { get; set; } + public string? Category { get; set; } public string? EventKey { get; set; } public string? Title { get; set; } diff --git a/DorkNet.Server/Controllers/API/Leaderboard/LeaderboardController.cs b/DorkNet.Server/Controllers/API/Leaderboard/LeaderboardController.cs index cd4654a..93d7575 100644 --- a/DorkNet.Server/Controllers/API/Leaderboard/LeaderboardController.cs +++ b/DorkNet.Server/Controllers/API/Leaderboard/LeaderboardController.cs @@ -131,6 +131,106 @@ public class LeaderboardController( return Ok(new { success = true, error = "" }); } + /// POST /leaderboard/CheckAndSetStat — the compare-and-set + /// form of , and the ONLY stat-write route the + /// 2023-03-21 client has: that build contains no SetStat literal at + /// all. Nothing registered this route, so every leaderboard score written + /// by a 2023 client fell through to the catch-all and was lost. + /// + /// The request is RecNet.SetStatRequestDTO, whose property names are + /// NOT obfuscated in the dump: StatChannel (Int32), RoomId + /// (Int64), StatValue (Int32) and the extra + /// CurrentStatValue (Int32?) that distinguishes this route from + /// plain SetStat. + /// + /// CurrentStatValue is the value the client believes is currently + /// stored. It is an optimistic-concurrency guard: the write applies only if + /// the server agrees, which stops a stale session from clobbering a newer + /// score. A null means "no expectation" and writes unconditionally, and a + /// mismatch is reported rather than silently dropped so the client can + /// re-read. + /// + /// As with SetStat, the client has already decided the new value should win + /// (it applies the per-channel higher/lower-is-better rule locally), so the + /// server does not second-guess the direction. + [HttpPost("/leaderboard/CheckAndSetStat")] + [HttpPost("/api/leaderboard/CheckAndSetStat")] + [Authorize] + public async Task CheckAndSetStat([FromBody] CheckAndSetStatRequest req) + { + var pid = this.RequireCurrentPlayerId(); + var roomId = ResolveRoomId(req.RoomId, pid); + var row = await db.LeaderboardStats.FirstOrDefaultAsync(s => + s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel); + + if (req.CurrentStatValue is int expected && row is not null && row.Value != expected) + { + return Ok(new + { + success = false, + error = "stat_value_mismatch", + StatValue = row.Value, + CurrentStatValue = row.Value, + }); + } + + if (row is null) + { + row = new LeaderboardStatEntity + { + PlayerId = pid, + RoomId = roomId, + StatChannel = req.StatChannel, + Value = req.StatValue, + }; + db.LeaderboardStats.Add(row); + } + else + { + row.Value = req.StatValue; + row.UpdatedAt = DateTime.UtcNow; + } + + await EnsureChannelMetaAsync(roomId, req.StatChannel); + await db.SaveChangesAsync(); + return Ok(new + { + success = true, + error = "", + StatValue = row.Value, + CurrentStatValue = row.Value, + }); + } + + public sealed class CheckAndSetStatRequest + { + public int StatChannel { get; set; } + public long RoomId { get; set; } + public int StatValue { get; set; } + + /// The value the client believes is stored. Null = write + /// unconditionally. + public int? CurrentStatValue { get; set; } + } + + /// Stamp a placeholder channel-meta row the first time a channel + /// reports, so the admin SPA's per-room Leaderboards tab surfaces it. + private async Task EnsureChannelMetaAsync(long roomId, int statChannel) + { + if (roomId <= 0) return; + var meta = await db.LeaderboardChannelMeta + .FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == statChannel); + if (meta is not null) return; + db.LeaderboardChannelMeta.Add(new LeaderboardChannelMetaEntity + { + Channel = statChannel, + RoomId = roomId, + Name = $"Channel {statChannel}", + LowerIsBetter = false, + ValueFormat = "count", + }); + } + public sealed class GetPlayerRankRequest { public int PlayerId { get; set; } diff --git a/DorkNet.Server/Controllers/API/Moderation/InGameModerationController.cs b/DorkNet.Server/Controllers/API/Moderation/InGameModerationController.cs index 1408165..1b18de8 100644 --- a/DorkNet.Server/Controllers/API/Moderation/InGameModerationController.cs +++ b/DorkNet.Server/Controllers/API/Moderation/InGameModerationController.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -105,9 +106,23 @@ public class InGameModerationController( // ── /v1/instantKick — admin one-shot ───────────────────────────── /// - /// Admin/dev one-shot kick. The watch posts either + /// Admin/dev one-shot kick. The 2020.12 watch posts either /// ?playerId=N for a single target (PlayerReporting.txt:3417) - /// or a list body for bulk kicks (3590). Either form lands here. + /// or a list body for bulk kicks (3590). + /// + /// The 2023-03-21 client posts neither: it serialises a + /// RecNet.KickPlayerDTO with JsonUtility.ToJson and sends it + /// as a RAW JSON body (RecNet.Runtime/FPIBGPIAOBI.txt:5012 JsonUtility.ToJson, + /// :5018 BNDIAONDFFF.FJLLPHFOOJJ raw-body setter, :5034 verb rdx=2). + /// KickPlayerDTO's fields are not obfuscated — public long GameSessionId + /// and public List<int> PlayerIds — so the body is exactly + /// {"GameSessionId":123,"PlayerIds":[456]}. Unity's JsonUtility emits + /// field names verbatim. + /// + /// None of the query/form parameters bound against that body, so the target + /// list came back empty and every 2023 instant-kick returned + /// "missing_target" and kicked nobody. + /// /// Gated to / /// . /// @@ -130,6 +145,7 @@ public class InGameModerationController( foreach (var part in formIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) if (long.TryParse(part, out var v) && v > 0) ids.Add(v); } + ids.AddRange(await ReadKickPlayerDtoIdsAsync()); ids = ids.Distinct().ToList(); if (ids.Count == 0) return Ok(new { success = false, error = "missing_target" }); @@ -143,6 +159,47 @@ public class InGameModerationController( return Ok(new { success = true, error = "" }); } + /// Read the 2023 client's raw KickPlayerDTO JSON body. + /// Returns an empty list for a form post or an unparseable body so the + /// older query/form paths are unaffected. + private async Task> ReadKickPlayerDtoIdsAsync() + { + var ids = new List(); + if (Request.HasFormContentType) return ids; + + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await JsonDocument.ParseAsync(Request.Body); + Request.Body.Position = 0; + if (doc.RootElement.ValueKind != JsonValueKind.Object) return ids; + + foreach (var name in new[] { "PlayerIds", "playerIds" }) + { + if (!doc.RootElement.TryGetProperty(name, out var arr) || + arr.ValueKind != JsonValueKind.Array) continue; + foreach (var el in arr.EnumerateArray()) + if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v) && v > 0) + ids.Add(v); + break; + } + + foreach (var name in new[] { "PlayerId", "playerId" }) + { + if (doc.RootElement.TryGetProperty(name, out var one) && + one.ValueKind == JsonValueKind.Number && + one.TryGetInt64(out var v) && v > 0) + { + ids.Add(v); + break; + } + } + } + catch (JsonException) { /* not a JSON body */ } + return ids; + } + // ── /v1/deviceId — log mismatched device id ────────────────────── [HttpPost("api/PlayerReporting/v1/deviceId")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] @@ -192,11 +249,21 @@ public class InGameModerationController( "[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}", caller, target, reason, p.BannedUntil, displayReason); - // Push a ModerationRoomBan notification so the target watch - // shows the ban dialog and disconnects (PlayerReporting.txt:2253). - await notifications.NotifyAsync( + // Push the ban as a ModerationKick (22) with IsBan set, NOT as + // ModerationRoomBan (24): the 2023-03-21 client's moderation manager + // registers only 22, "ModerationUnkick" and 23 + // (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler + // for 24, so that push landed nowhere and the banned player kept playing + // until their next login. The 22 handler reads Msg.Reason as a + // ModerationBlockDetail, which ModerationKickPayload already produces. + await notifications.KickPlayerAsync( target, - Models.Notification.PushNotificationId.ModerationRoomBan); + // `reason` is the numeric report category; DisplayReason is the + // moderator's text and is what the ban dialog shows. + string.IsNullOrWhiteSpace(displayReason) + ? "You have been banned from this room." + : displayReason.Trim(), + isBan: true); return Ok(new { success = true, error = "" }); } diff --git a/DorkNet.Server/Controllers/API/Photos/PhotosController.cs b/DorkNet.Server/Controllers/API/Photos/PhotosController.cs index e854b25..d42e86b 100644 --- a/DorkNet.Server/Controllers/API/Photos/PhotosController.cs +++ b/DorkNet.Server/Controllers/API/Photos/PhotosController.cs @@ -1,3 +1,5 @@ +using System.Text; +using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -240,6 +242,318 @@ public class PhotosController( return Ok(new { deleted = id }); } + // ── 2023 home feed (Discovery host) ─────────────────────────────── + + /// The only FeedItemType (client enum + /// DAAEHNDDCCH) the 2023 feed list can render: an image. + /// Two independent confirmations in the binary — the list spawner + /// builds one RRUI.Data.ImageFeedItemModel per returned item + /// (Assembly-CSharp/RRUI/Data/FeedListModelController_NestedType_FeedListSpawnerImpl.txt:294 + /// "044 Call ImageFeedItemModel.Set"), and the share deep-link + /// handler maps the path segment "image" + an Int64 id onto an + /// injected feed item constructed with the enum literal 0 + /// (Assembly-CSharp/RecRoom/Sharing/LinkManager.txt:5646 + /// Move rdx,"image" … :5665 Move rdx,0 … :5668 Call + /// EKLOCIPFHII..ctor). FeedItemId is therefore an image id, which on + /// DorkNet is — the same id space + /// api/images/v5/bulk resolves. + private const int FeedItemTypeImage = 0; + + /// Ranking revision stamped on the envelope and on every + /// item. The client never interprets it: it copies the Int16 off the + /// response into the list model and replays it in the impression + /// telemetry it later posts back (FeedListModel_NestedType_OLMJPGEMAKM.txt + /// "031 Move rax,[rdi+40] / 032 Move [rcx+280],rax" then + /// "040 Call FeedListModel.LMDNOKJCBGE"). Bump it when the ordering + /// below changes so the telemetry can tell the cohorts apart. + private const short FeedAlgoVersion = 1; + + /// POST /feed/query — the 2023 client's personalised home + /// feed. Host is Discovery (host enum 19) and the verb is POST: + /// RecNet.Runtime/KGLCPCEGCFF.txt:166-178 loads + /// r9 = "feed/query", r8 = 19, rdx = 2 (BestHTTP POST) into the + /// shared BNDIAONDFFF request ctor. The body is a RAW JSON document + /// (the issuing method takes an already-serialised + /// System.String and hands it to BNDIAONDFFF.FJLLPHFOOJJ), + /// NOT form fields — so it is read straight off Request.Body. + /// + /// Request keys (reader BAEBDMPPKKE.txt:331-414, each registered in + /// PascalCase/camelCase/lowercase): Take:Int32, + /// ContinuationToken:String?, InjectedFeedItems:[{FeedItemType, + /// FeedItemId}], FeedInstanceId:String?. + /// + /// Response is the generic result envelope AECMPGPHAII<T> + /// {Success, Error, Value} (keys at AEGADMDIKNE.txt:243-286) wrapping + /// BDIFPHKLFKD {Items, FeedInstanceId, FeedContext, + /// FeedAlgorithmVersion} (keys at EHKEOCKBCDH.txt:331-414). Anything + /// else — including a 404 — rejects the promise and the home tab + /// renders its failure state. + /// + /// FeedContext is the continuation cursor, not a display string: the + /// RefreshPage continuation reads field +32 (FeedContext) as the + /// third element of its (count, items, nextToken) tuple and feeds it + /// back as the next request's ContinuationToken + /// (FeedListModel_NestedType_OLMJPGEMAKM.txt "062 Move r9, + /// [rdi+32]"). An empty FeedContext therefore ends pagination. + /// + /// Anonymous-safe like the other feed reads; when the caller is + /// authenticated the ordering is personalised (friends/subscriptions + /// first) and blocked/ignored uploaders are filtered out. + [HttpPost("/feed/query")] + [AllowAnonymous] + public async Task FeedQuery() + { + var take = 20; + string? continuationToken = null; + string? requestedInstanceId = null; + var requestedInjected = new List(); + + try + { + using var doc = await JsonDocument.ParseAsync(Request.Body); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + var root = doc.RootElement; + if (TryFeedProp(root, "Take", out var takeEl) && + takeEl.ValueKind == JsonValueKind.Number && takeEl.TryGetInt32(out var t)) + take = t; + if (TryFeedProp(root, "ContinuationToken", out var ctEl) && + ctEl.ValueKind == JsonValueKind.String) + continuationToken = ctEl.GetString(); + if (TryFeedProp(root, "FeedInstanceId", out var fiEl) && + fiEl.ValueKind == JsonValueKind.String) + requestedInstanceId = fiEl.GetString(); + if (TryFeedProp(root, "InjectedFeedItems", out var injEl) && + injEl.ValueKind == JsonValueKind.Array) + { + foreach (var entry in injEl.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) continue; + // Non-image injected items can't be rendered by the + // 2023 list, so echoing them back would produce a + // blank card. Drop them. + var type = TryFeedProp(entry, "FeedItemType", out var tyEl) && + tyEl.ValueKind == JsonValueKind.Number && + tyEl.TryGetInt32(out var ty) + ? ty : FeedItemTypeImage; + if (type != FeedItemTypeImage) continue; + if (TryFeedProp(entry, "FeedItemId", out var idEl) && + idEl.ValueKind == JsonValueKind.Number && + idEl.TryGetInt64(out var id) && id > 0) + requestedInjected.Add(id); + } + } + } + } + catch (JsonException) + { + // Unparseable/empty body — treat as a default first-page pull + // rather than 400, so a malformed retry still shows content. + } + + take = Math.Clamp(take, 1, 50); + var me = this.CurrentPlayerId(); + + // Uploaders the caller can't see: anyone they blocked or ignored, + // plus anyone who blocked them. + var hidden = new List(); + var affinity = new List(); + if (me is long meId) + { + hidden = await db.Relationships + .Where(r => (r.RequesterId == meId && + (r.Status == RelationshipStatus.Blocked || r.Ignored)) || + (r.TargetId == meId && r.Status == RelationshipStatus.Blocked)) + .Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId) + .Distinct() + .ToListAsync(); + + var friends = await db.Relationships + .Where(r => r.Status == RelationshipStatus.Friend && + (r.RequesterId == meId || r.TargetId == meId)) + .Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId) + .ToListAsync(); + var follows = await db.Subscriptions + .Where(s => s.SubscriberPlayerId == meId) + .Select(s => s.TargetPlayerId) + .ToListAsync(); + affinity = friends.Concat(follows) + .Where(id => id > 0 && !hidden.Contains(id)) + .Distinct() + .ToList(); + } + + var resumed = TryDecodeFeedCursor(continuationToken, + out var phase, out var cursorTicks, out var cursorId, out var carriedInjected); + + // Injected items are pinned to the head of the FIRST page only, in + // the order the client asked for them (they come from a share + // deep-link the player just followed). Their ids ride along in the + // cursor so later pages don't repeat them organically. + var injectedIds = resumed ? carriedInjected : requestedInjected.Distinct().ToArray(); + var itemIds = new List(); + if (!resumed && injectedIds.Length > 0) + { + var pinned = await VisibleFeedPhotos(hidden) + .Where(p => injectedIds.Contains(p.Id)) + .Select(p => p.Id) + .ToListAsync(); + itemIds.AddRange(injectedIds.Where(pinned.Contains)); + } + + DateTime? cutoffAt = resumed && cursorTicks > 0 && cursorTicks <= DateTime.MaxValue.Ticks + ? new DateTime(cursorTicks) + : (DateTime?)null; + var cutoffId = resumed ? cursorId : 0L; + if (phase < 0) phase = 0; + + // Phase 0 = photos from people the caller follows or is friends + // with; phase 1 = everyone else. Both are newest-first, and the + // cursor is (CreatedAt, Id) so a photo posted mid-scroll can never + // shift a row onto a page the client already consumed. + while (itemIds.Count < take && phase <= 1) + { + if (phase == 0 && affinity.Count == 0) + { + phase = 1; + cutoffAt = null; + cutoffId = 0; + continue; + } + + var need = take - itemIds.Count; + var q = VisibleFeedPhotos(hidden); + if (injectedIds.Length > 0) + q = q.Where(p => !injectedIds.Contains(p.Id)); + q = phase == 0 + ? q.Where(p => affinity.Contains(p.UploaderPlayerId)) + : q.Where(p => !affinity.Contains(p.UploaderPlayerId)); + if (cutoffAt is DateTime at) + { + var atId = cutoffId; + q = q.Where(p => p.CreatedAt < at || (p.CreatedAt == at && p.Id < atId)); + } + + var batch = await q + .OrderByDescending(p => p.CreatedAt) + .ThenByDescending(p => p.Id) + .Take(need) + .Select(p => new { p.Id, p.CreatedAt }) + .ToListAsync(); + itemIds.AddRange(batch.Select(b => b.Id)); + + if (batch.Count < need) + { + phase++; + cutoffAt = null; + cutoffId = 0; + } + else + { + cutoffAt = batch[^1].CreatedAt; + cutoffId = batch[^1].Id; + } + } + + // Empty FeedContext = end of the feed; the client stops paging. + var nextContext = phase > 1 + ? string.Empty + : EncodeFeedCursor(phase, cutoffAt?.Ticks ?? 0, cutoffId, injectedIds); + + return Ok(new + { + Success = true, + Error = (string?)null, + Value = new + { + Items = itemIds.Select(id => new + { + FeedItemType = FeedItemTypeImage, + FeedItemId = id, + FeedAlgorithmVersion = FeedAlgoVersion, + }).ToList(), + // The client sends the instance id it generated for this + // scroll session and stores whatever comes back; echoing it + // keeps its impression telemetry stitched to one session. + FeedInstanceId = string.IsNullOrWhiteSpace(requestedInstanceId) + ? Guid.NewGuid().ToString("N") + : requestedInstanceId, + FeedContext = nextContext, + FeedAlgorithmVersion = FeedAlgoVersion, + }, + }); + } + + /// Photos eligible for the public home feed: live, public, + /// and not from an uploader the caller blocked/ignored. + private IQueryable VisibleFeedPhotos(List hidden) + { + var q = db.Photos.Where(p => p.IsPublic && p.DeletedAt == null); + if (hidden.Count > 0) + q = q.Where(p => !hidden.Contains(p.UploaderPlayerId)); + return q; + } + + /// Case-insensitive property lookup — the 2023 generated + /// writers emit PascalCase but their readers register all three + /// casings, so tolerate whatever the client sends. + private static bool TryFeedProp(JsonElement obj, string name, out JsonElement value) + { + foreach (var prop in obj.EnumerateObject()) + { + if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase)) + { + value = prop.Value; + return true; + } + } + value = default; + return false; + } + + /// Opaque base64url cursor handed to the client as + /// FeedContext. Carries the phase, the (CreatedAt, Id) keyset + /// position within that phase, and the pinned injected ids so they + /// stay suppressed on later pages. + private static string EncodeFeedCursor(int phase, long ticks, long lastId, IReadOnlyList injected) + { + var raw = $"1|{phase}|{ticks}|{lastId}|{string.Join(',', injected)}"; + return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static bool TryDecodeFeedCursor(string? token, out int phase, + out long ticks, out long lastId, out long[] injected) + { + phase = 0; + ticks = 0; + lastId = 0; + injected = Array.Empty(); + if (string.IsNullOrWhiteSpace(token)) return false; + try + { + var b64 = token.Replace('-', '+').Replace('_', '/'); + b64 = b64.PadRight(b64.Length + ((4 - (b64.Length % 4)) % 4), '='); + var parts = Encoding.UTF8.GetString(Convert.FromBase64String(b64)).Split('|'); + if (parts.Length != 5 || parts[0] != "1") return false; + if (!int.TryParse(parts[1], out phase)) return false; + if (!long.TryParse(parts[2], out ticks)) return false; + if (!long.TryParse(parts[3], out lastId)) return false; + injected = parts[4] + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => long.TryParse(s, out var v) ? v : 0L) + .Where(v => v > 0) + .ToArray(); + return true; + } + catch (FormatException) + { + // Garbage/stale cursor — restart the feed from the top instead + // of failing the whole pull. + return false; + } + } + // ── Helpers ────────────────────────────────────────────────────── /// Trim/dedupe tagged ids and drop the uploader's own id. diff --git a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs index 9bc0d00..313abd1 100644 --- a/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs +++ b/DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Globalization; using System.Text.Json; using DorkNet.Models.Notification; using DorkNet.Server.Auth; @@ -11,25 +12,49 @@ using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.PlayerEvents; /// -/// api.rec.net/api/playerevents/v1/* — create, list, RSVP for -/// player-scheduled gatherings. +/// api.rec.net/api/playerevents/v1|v2/* — create, browse, edit, RSVP and +/// broadcast for player-scheduled gatherings. +/// +/// The 2023-03-21 client's event API surface is RecNet.Runtime/CBKANFIOBCF +/// (route literals at CBKANFIOBCF.txt:161-4156). Three wire types drive +/// nearly every handler here: +/// * HPIOAGDJHDH (PlayerEvent) — 17 keys, formatter +/// HPMMKFGDAEC.txt:1139-1518. +/// * MDCBEPJCJPO (PlayerEvent + Tags:[{Tag,Type}]) — formatter +/// CENEMCMGDKG.txt:1191-1586, tag entry keys +/// PCMLHLIBLNJ.txt:191-218. +/// * PHHAKLPGNGC ({PlayerEvent,Result,TagModifyResult}) — +/// formatter EEAAGIJHLOJ.txt:267-326, TagModifyResult keys +/// ALJPHDEAHBK.txt:191-218. Every v2 create/edit/delete/field +/// mutation deserialises this wrapper, so a flat event body leaves the +/// client's PlayerEvent null after a save. /// [ApiController] [Authorize] public class PlayerEventsController( DorkNetDbContext db, - NotificationService notifications) : ControllerBase + NotificationService notifications, + PlayerPresenceService presence) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); - // ── Browse (v2) ────────────────────────────────────────────────────── + /// PlayerEventResponseType.NotGoing — the one RSVP state that + /// does not count towards AttendeeCount. + private const int NotGoingResponse = 2; - /// GET api/playerevents/v2 — upcoming events list. - /// Returns the next 50 events ordered by start time. Wire shape - /// is the standard PlayerEvent (Id, CreatorPlayerId, RoomId, - /// Title, Description, StartsAt, EndsAt, Capacity). + // ── Browse ─────────────────────────────────────────────────────────── + + /// GET api/playerevents/v2 (and v1) — upcoming + /// events list, a BARE JSON array of PlayerEvent. + /// + /// The 2023 watch's Events tab reads the unversioned path: HIHEFBMPOGC + /// returns List<HPIOAGDJHDH> and dispatches with verb 0 + /// (CBKANFIOBCF.txt:1305, route literal :1304). Only + /// [HttpPost] was registered on api/playerevents/v1, so the + /// whole tab 405'd. [AllowAnonymous] [HttpGet("api/playerevents/v2")] + [HttpGet("api/playerevents/v1")] public async Task ListUpcoming( [FromQuery] int skip = 0, [FromQuery] int take = 50) { @@ -41,9 +66,17 @@ public class PlayerEventsController( .Skip(skip) .Take(take) .ToListAsync(); - return Ok(rows.Select(ToWire)); + return Ok(await ToWireManyAsync(rows)); } + /// GET api/playerevents/v1|v2/{id}. The client calls this + /// three ways — plain (NLEFILEAMIP → HPIOAGDJHDH) and with + /// ?includeDetails= / ?clubId= (HLJBCGMONEH / OAKKDBLNKLG → + /// MDCBEPJCJPO, query keys at + /// CBKANFIOBCF_NestedType_DEIBMMABHIK.txt:86,99). Both DTOs are the + /// same object, the detail one just adds Tags, and the Utf8Json + /// formatters skip unknown keys — so we always emit the superset and no + /// branch is needed. [AllowAnonymous] [HttpGet("api/playerevents/v2/{eventId:long}")] [HttpGet("api/playerevents/v1/{eventId:long}")] @@ -51,23 +84,28 @@ public class PlayerEventsController( { var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); if (ev is null) return NotFound(); - return Ok(ToWire(ev)); + return Ok(await ToWireOneAsync(ev)); } + /// GET api/playerevents/v1/search. Query keys are + /// query, sort and scheduleFilter + /// (CBKANFIOBCF_NestedType_IONCOJJGHNJ.txt:99,113,126); the latter + /// two are enum ordinals (PJBLEKMMACM / BJBLPLKMLBE). [AllowAnonymous] [HttpGet("api/playerevents/v1/search")] - [HttpGet("api/playerevents/v1/searchlive")] public async Task Search( [FromQuery] string? q = null, [FromQuery] string? query = null, + [FromQuery] int? sort = null, + [FromQuery] int? scheduleFilter = null, [FromQuery] int skip = 0, [FromQuery] int take = 50) { var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant(); skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 100); - var rows = db.PlayerEvents - .Where(e => e.EndsAt > DateTime.UtcNow); + + var rows = ApplyScheduleFilter(db.PlayerEvents.AsQueryable(), scheduleFilter); if (needle.Length > 0) { rows = rows.Where(e => @@ -75,12 +113,70 @@ public class PlayerEventsController( e.Description.ToLower().Contains(needle)); } - var result = await rows - .OrderBy(e => e.StartsAt) + var result = await ApplySort(rows, sort) .Skip(skip) .Take(take) .ToListAsync(); - return Ok(result.Select(ToWire)); + return Ok(await ToWireManyAsync(result)); + } + + /// GET api/playerevents/v1/searchlive — "happening now". + /// Separate handler from because the response element + /// type differs: FIKHCEPDIBF returns List<CLDDIKOJMAM>, which + /// is the PlayerEvent object plus PlayerCount (Int32) and + /// IsFull (Boolean) — formatter PDIFGFKAMBG.txt:1267,1294. + /// Head-count comes from the room's live GameSessions rows (their RoomId is + /// the numeric room id as a string, see + /// Services/GameSessionService.cs:91-96). + [AllowAnonymous] + [HttpGet("api/playerevents/v1/searchlive")] + public async Task SearchLive( + [FromQuery] string? q = null, + [FromQuery] string? query = null, + [FromQuery] int take = 50) + { + var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant(); + take = Math.Clamp(take, 1, 100); + + var now = DateTime.UtcNow; + var live = db.PlayerEvents.Where(e => e.StartsAt <= now && e.EndsAt > now); + if (needle.Length > 0) + { + live = live.Where(e => + e.Title.ToLower().Contains(needle) || + e.Description.ToLower().Contains(needle)); + } + + var rows = await live.OrderBy(e => e.StartsAt).Take(take).ToListAsync(); + var wire = await ToWireManyAsync(rows); + + var roomKeys = rows.Select(r => r.RoomId.ToString()).Distinct().ToList(); + var sessions = await db.GameSessions + .Where(s => roomKeys.Contains(s.RoomId)) + .GroupBy(s => s.RoomId) + .Select(g => new + { + RoomId = g.Key, + Players = g.Sum(s => s.PlayerCount), + Capacity = g.Sum(s => s.MaxCapacity), + }) + .ToListAsync(); + var byRoom = sessions.ToDictionary(s => s.RoomId, s => s); + + for (var i = 0; i < rows.Count; i++) + { + var players = 0; + var capacity = 0; + if (byRoom.TryGetValue(rows[i].RoomId.ToString(), out var agg)) + { + players = agg.Players; + capacity = agg.Capacity; + } + wire[i]["PlayerCount"] = players; + wire[i]["IsFull"] = capacity > 0 && players >= capacity; + } + + return Ok(wire); } [AllowAnonymous] @@ -92,53 +188,53 @@ public class PlayerEventsController( .OrderBy(e => e.StartsAt) .Take(100) .ToListAsync(); - return Ok(rows.Select(ToWire)); + return Ok(await ToWireManyAsync(rows)); } + /// GET api/playerevents/v1/club/{clubId}. PHNEDJAPIKI + /// returns IOKLNPFOLGI — an OBJECT + /// {Events:[PlayerEvent],ContinuationToken:String} + /// (GHBIGGJKPFI.txt:203-238), not a bare array, and passes + /// take / continuationToken query params + /// (CBKANFIOBCF_NestedType_PKDDOCBDBIM.txt:80,89). The token here is + /// just the next skip offset; empty string means "no more pages" (the + /// client's field is a plain String, so never emit null). [AllowAnonymous] [HttpGet("api/playerevents/v1/club/{clubId:long}")] - public async Task ForClub(long clubId) + public async Task ForClub( + long clubId, + [FromQuery] int? take = null, + [FromQuery] string? continuationToken = null) { - var memberIds = await db.ClubMemberships - .Where(m => m.ClubId == clubId) - .Select(m => m.PlayerId) - .ToListAsync(); - if (memberIds.Count == 0) return Ok(Array.Empty()); + var pageSize = Math.Clamp(take ?? 50, 1, 100); + var skip = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0; - var roomIds = await db.Rooms - .Where(r => memberIds.Contains(r.CreatorPlayerId)) - .Select(r => r.Id) - .ToListAsync(); - if (roomIds.Count == 0) return Ok(Array.Empty()); + // Fetch one extra row to find out whether another page exists. + var rows = await ClubEventsAsync(new[] { clubId }, skip, pageSize + 1); + var hasMore = rows.Count > pageSize; + if (hasMore) rows.RemoveAt(rows.Count - 1); - var rows = await db.PlayerEvents - .Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow) - .OrderBy(e => e.StartsAt) - .Take(100) - .ToListAsync(); - return Ok(rows.Select(ToWire)); + return Ok(new + { + Events = await ToWireManyAsync(rows), + ContinuationToken = hasMore ? (skip + pageSize).ToString() : string.Empty, + }); } + /// GET api/playerevents/v1/clubs — events for a specific + /// set of clubs. IEGLMOOPLED appends one repeated id query value per + /// club (CBKANFIOBCF_NestedType_LKJFABHPIAN.txt:59); ignoring them + /// returned every club's events and the watch could not bucket the list. + /// [AllowAnonymous] [HttpGet("api/playerevents/v1/clubs")] public async Task ClubEvents([FromQuery] int take = 100) { take = Math.Clamp(take, 1, 200); - var clubPlayerIds = await db.ClubMemberships - .Select(m => m.PlayerId) - .Distinct() - .ToListAsync(); - if (clubPlayerIds.Count == 0) return Ok(Array.Empty()); - var roomIds = await db.Rooms - .Where(r => clubPlayerIds.Contains(r.CreatorPlayerId)) - .Select(r => r.Id) - .ToListAsync(); - var rows = await db.PlayerEvents - .Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow) - .OrderBy(e => e.StartsAt) - .Take(take) - .ToListAsync(); - return Ok(rows.Select(ToWire)); + var clubIds = new List(); + foreach (var value in Request.Query["id"]) AddDelimitedIds(clubIds, value); + var rows = await ClubEventsAsync(clubIds.Distinct().ToList(), 0, take); + return Ok(await ToWireManyAsync(rows)); } public sealed class EventBulkRequest @@ -148,6 +244,10 @@ public class PlayerEventsController( public List? Ids { get; set; } } + /// POST api/playerevents/v1/bulk — verb 2 at + /// CBKANFIOBCF.txt:660; the form field is Ids + /// (CBKANFIOBCF_NestedType_PIKPJPBHOAC.txt:64). GET is kept for the + /// 2020 client, which puts the ids in the query string. [AllowAnonymous] [HttpPost("api/playerevents/v1/bulk")] [HttpGet("api/playerevents/v1/bulk")] @@ -158,21 +258,29 @@ public class PlayerEventsController( var rows = await db.PlayerEvents .Where(e => ids.Contains(e.Id)) .ToListAsync(); - return Ok(rows.Select(ToWire)); + return Ok(await ToWireManyAsync(rows)); } + /// GET api/playerevents/v1/tagfilters — AKCLLEJNFFD reads + /// three String lists: PinnedFilters, PopularFilters and + /// TrendingFilters (HMDONHHKKGA.txt:279-346). A missing key + /// leaves the list null and the chips UI NREs when it enumerates. [AllowAnonymous] [HttpGet("api/playerevents/v1/tagfilters")] public IActionResult TagFilters() => Ok(new { PinnedFilters = new[] { "game", "social", "competition", "class", "club" }, PopularFilters = new[] { "game", "social", "competition", "class", "club" }, + TrendingFilters = new[] { "game", "social", "competition", "class", "club" }, }); /// GET api/playerevents/v1/all — the watch's - /// LocalPlayerEventInfo fetch (caller's created + RSVP'd - /// events). Response keys (both required, via - /// Util.GetObjectListKey): Created, Responses. + /// LocalPlayerEventInfo fetch (caller's created + RSVP'd events). + /// PCELKKHNPHJ is {Created:[PlayerEvent], Responses:[COAGAJPELCG]} + /// (ENJDEONHNMO.txt:191-218) where each response entry is + /// {PlayerEvent, PlayerEventResponse} (HJKLIIPPPEM.txt:215-258) + /// — a flat response row leaves PlayerEvent null and the My Events + /// page NREs. [AllowAnonymous] [HttpGet("api/playerevents/v1/all")] public async Task AllPlayerEvents() @@ -180,99 +288,572 @@ public class PlayerEventsController( var pid = this.CurrentPlayerId(); if (pid is not long me) return Ok(new { Created = Array.Empty(), Responses = Array.Empty() }); - - var created = await db.PlayerEvents - .Where(e => e.CreatorPlayerId == me) - .OrderByDescending(e => e.StartsAt) - .Select(e => new - { - e.Id, - e.Title, - e.Description, - e.RoomId, - e.StartsAt, - e.EndsAt, - e.Capacity, - }) - .ToListAsync(); - - var responses = await db.PlayerEventResponses - .Where(r => r.PlayerId == me) - .Select(r => new - { - r.Id, - r.EventId, - r.Response, - r.CreatedAt, - }) - .ToListAsync(); - - return Ok(new { Created = created, Responses = responses }); + return await AllForPlayerAsync(me); } [AllowAnonymous] [HttpGet("api/playerevents/v1/all/{accountId:long}")] - public async Task AllPlayerEventsForAccount(long accountId) + public Task AllPlayerEventsForAccount(long accountId) + => AllForPlayerAsync(accountId); + + private async Task AllForPlayerAsync(long playerId) { var created = await db.PlayerEvents - .Where(e => e.CreatorPlayerId == accountId) + .Where(e => e.CreatorPlayerId == playerId) .OrderByDescending(e => e.StartsAt) - .Select(e => new - { - e.Id, - e.Title, - e.Description, - e.RoomId, - e.StartsAt, - e.EndsAt, - e.Capacity, - }) + .Take(200) .ToListAsync(); var responses = await db.PlayerEventResponses - .Where(r => r.PlayerId == accountId) - .Select(r => new - { - r.Id, - r.EventId, - r.Response, - r.CreatedAt, - }) + .Where(r => r.PlayerId == playerId) + .OrderByDescending(r => r.CreatedAt) + .Take(200) .ToListAsync(); - return Ok(new { Created = created, Responses = responses }); + // Each response entry embeds its own event, which may not be one the + // caller created — pull those in too so PlayerEvent is never null. + var createdIds = created.Select(e => e.Id).ToHashSet(); + var missingIds = responses.Select(r => r.EventId).Where(id => !createdIds.Contains(id)).Distinct().ToList(); + var extraEvents = new List(); + if (missingIds.Count > 0) + extraEvents = await db.PlayerEvents.Where(e => missingIds.Contains(e.Id)).ToListAsync(); + + var all = created.Concat(extraEvents).ToList(); + var wire = await ToWireMapAsync(all); + + return Ok(new + { + Created = created.Select(e => wire[e.Id]).ToList(), + Responses = responses + .Where(r => wire.ContainsKey(r.EventId)) + .Select(r => new + { + PlayerEvent = wire[r.EventId], + PlayerEventResponse = ResponseWire(r), + }) + .ToList(), + }); } - /// POST api/playerevents/v1/{eventId}/responses — - /// alternate RSVP route the watch uses for the response-list - /// flow (older / sub-room view). Body is the same RsvpRequest - /// as v1/{id}/rsvp; behaves identically. - [HttpPost("api/playerevents/v1/{eventId:long}/responses")] - public Task RespondList(long eventId, [FromBody] RsvpRequest body) - => Rsvp(eventId, body); - - /// Wire shape verified against - /// Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815: - /// PascalCase keys PlayerEventId, Name, Description, - /// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId, - /// ImageName, Accessibility. Server-side we store these - /// under different names (Title, StartsAt, EndsAt, Capacity) — - /// remap explicitly here so the watch's - /// PlayerEvent.Deserialize picks them up. - private static object ToWire(PlayerEventEntity ev) => new + /// GET api/playerevents/v1/{eventId}/responses — the event + /// detail page's attendee list. DFABBIDLDPE dispatches with verb 0 + /// (CBKANFIOBCF.txt:1699, route literal :1689) and reads + /// List<JPIKCIGABBI>; the path was registered POST-only, so + /// the list 405'd. + [AllowAnonymous] + [HttpGet("api/playerevents/v1/{eventId:long}/responses")] + public async Task ResponsesForEvent(long eventId) { - PlayerEventId = ev.Id, - Name = ev.Title, - ev.Description, - StartTime = ev.StartsAt, - EndTime = ev.EndsAt, - CreatorPlayerId = (int)ev.CreatorPlayerId, - AttendeeCount = 0, // computed at query time when needed - ev.RoomId, - ImageName = string.Empty, - Accessibility = 1, // Public + var rows = await db.PlayerEventResponses + .Where(r => r.EventId == eventId) + .OrderBy(r => r.CreatedAt) + .Take(500) + .ToListAsync(); + return Ok(rows.Select(ResponseWire).ToList()); + } + + // ── Event instance browser ─────────────────────────────────────────── + + /// GET event/{eventId}/instances — the "which instance of + /// this event are my friends in" browser behind the event card's join + /// button. Served from the MATCHMAKING-HOST root (no api/ prefix), + /// which is why it lives on an absolute route here even though the feature + /// is a player-event one. + /// + /// Binary evidence: Matchmaking+<GetEventInstanceBrowser>b__0 + /// (Matchmaking_NestedType_CBMHJMNIHNN.txt:14) formats the literal + /// "event/{0}/instances" at :296 with [rdi+16] — the + /// PlayerEventId off the HPIOAGDJHDH the lambda closes over — then news up + /// BNDIAONDFFF with rdx = 0 at :124, i.e. + /// BestHTTP.HTTPMethods.Get. The declared return type is + /// FGLDKEJLAKB<List<PNDCMIMEJLD>>, so the body is a BARE + /// JSON ARRAY. PNDCMIMEJLD's getters (PNDCMIMEJLD.txt:3-197) are, in + /// order, Int64/Int64/Int64/Boolean/DateTime/List<Int32>/String/Int32/ + /// Boolean/String = RoomInstanceId, RoomId, SubRoomId, IsFull, CreatedAt, + /// PlayerIds, SubroomName, PlayerCount, HasModPresent, HashedInstanceId — + /// byte-for-byte the SimpleRoomInstance the room/{id}/instances + /// browser already returns (Matchmaking.txt:14237 issues the room + /// variant through the identical BNDIAONDFFF/verb-0 sequence). The last + /// four are filled in locally on the watch and are not read back off the + /// wire, so we emit the same six camelCase keys + /// (Controllers/Match/MatchController.cs:545-553); the 2023 Utf8Json + /// formatters carry a camelCase variant per key, so both client + /// generations parse it. + /// + /// The event itself only supplies the room to browse: an event is pinned to + /// one RoomId, plus the SubRoomId the host picked in the v2 create/edit body + /// (persisted in ). Sub-room hops allocate their + /// own instance ids and photon rooms, so when the event names a sub-room we + /// only surface instances sitting in it. + [AllowAnonymous] + [HttpGet("/event/{eventId:long}/instances")] + public async Task EventInstances(long eventId) + { + var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId); + if (evt is null) return NotFound(); + + var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; + var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null; + + // Same sourcing as the room browser: presence rows are the live truth + // (one instance per distinct PhotonRoomId), and the caller's OWN private + // instances are merged in so the host can still see the instance they + // created before anyone has joined it. Another player's private + // PhotonRoomId is never listed. + var pid = this.CurrentPlayerId() ?? 0; + var privates = pid == 0 + ? new List() + : await db.PrivateInstances + .Where(p => p.RoomId == evt.RoomId && p.OwnerPlayerId == pid) + .ToListAsync(); + + var byKey = new Dictionary(StringComparer.Ordinal); + foreach (var (room, playerIds) in presence.EnumerateActiveInstances(evt.RoomId)) + { + if (string.IsNullOrEmpty(room.PhotonRoomId)) continue; + if (subRoomId is long want && room.SubRoomId != want) continue; + byKey[room.PhotonRoomId] = new + { + roomInstanceId = room.RoomInstanceId, + roomId = room.RoomId, + subRoomId = room.SubRoomId, + // MaxCapacity is the per-instance cap the matchmaker handed + // out, so this is a real answer rather than a constant false. + isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity, + // Presence rows are a TTL cache with no creation timestamp — + // there is no stored "instance opened at" for a live instance, + // so report the observation time. Private-instance rows below + // do carry a real CreatedAt and use it. + createdAt = DateTime.UtcNow, + playerIds = playerIds.Select(p => (int)p).ToArray(), + }; + } + + foreach (var p in privates) + { + if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue; + if (subRoomId is long want && p.SubRoomId != want) continue; + byKey[p.PhotonRoomId] = new + { + roomInstanceId = p.Id, + roomId = p.RoomId, + subRoomId = p.SubRoomId, + isFull = false, + createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc), + playerIds = Array.Empty(), + }; + } + + return Ok(byKey.Values.ToList()); + } + + // ── Wire shape ─────────────────────────────────────────────────────── + + /// The PlayerEvent fields the 2023 client carries that + /// has no column for. Adding columns needs + /// a migration in Data/Entities, so these live in the PlayerSettings + /// side-table: one JSON row per event under + /// playerevent:{id}:wire, plus a flat playerevent:{id}:club + /// row that keeps club lookups a single indexed query. + /// + /// Public (not private) purely so System.Text.Json's reflection-based + /// (de)serialiser can see the type and its accessors. + public sealed class EventExtras + { + public long? SubRoomId { get; set; } + public long? ClubId { get; set; } + public string ImageName { get; set; } = string.Empty; + /// BAMLHAODDOG ordinal. The enum's member order is not + /// recoverable from the ISIL (enums carry no method bodies), so events + /// that never had accessibility set keep the historical default of 1 + /// rather than guessing a new one; once the client PUTs a value we + /// echo exactly what it sent. + public int Accessibility { get; set; } = 1; + public bool IsMultiInstance { get; set; } + public bool SupportMultiInstanceRoomChat { get; set; } + public int DefaultBroadcastPermissions { get; set; } + public int CanRequestBroadcastPermissions { get; set; } + public long? BroadcastingRoomInstanceId { get; set; } + public List Tags { get; set; } = []; + } + + private static string ExtrasKey(long eventId) => $"playerevent:{eventId}:wire"; + private static string ClubKey(long eventId) => $"playerevent:{eventId}:club"; + + /// Wire shape verified against the client's Utf8Json formatter + /// HPMMKFGDAEC.txt:1139-1518 (and the details variant + /// CENEMCMGDKG.txt:1191-1586): PascalCase keys PlayerEventId, + /// CreatorPlayerId, RoomId, SubRoomId, ClubId, Name, Description, + /// ImageName, StartTime, EndTime, AttendeeCount, Accessibility, + /// IsMultiInstance, SupportMultiInstanceRoomChat, + /// DefaultBroadcastPermissions, CanRequestBroadcastPermissions, + /// BroadcastingRoomInstanceId — property types read off the getters in + /// HPIOAGDJHDH.txt. Server-side we store some of these under other + /// names (Title, StartsAt, EndsAt), so remap explicitly. + private static Dictionary ToWire( + PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new() + { + ["PlayerEventId"] = ev.Id, + ["CreatorPlayerId"] = (int)ev.CreatorPlayerId, + ["RoomId"] = ev.RoomId, + ["SubRoomId"] = extras.SubRoomId, + ["ClubId"] = extras.ClubId, + ["Name"] = ev.Title, + ["Description"] = ev.Description, + ["ImageName"] = extras.ImageName, + ["StartTime"] = ev.StartsAt, + ["EndTime"] = ev.EndsAt, + ["AttendeeCount"] = attendeeCount, + ["Accessibility"] = extras.Accessibility, + ["IsMultiInstance"] = extras.IsMultiInstance, + ["SupportMultiInstanceRoomChat"] = extras.SupportMultiInstanceRoomChat, + ["DefaultBroadcastPermissions"] = extras.DefaultBroadcastPermissions, + ["CanRequestBroadcastPermissions"] = extras.CanRequestBroadcastPermissions, + ["BroadcastingRoomInstanceId"] = extras.BroadcastingRoomInstanceId, + // MDCBEPJCJPO (the includeDetails DTO) is this same object plus + // Tags:[{Tag,Type}] (entry keys PCMLHLIBLNJ.txt:191-218). The tagless + // formatter skips unknown keys, so emitting Tags unconditionally is + // inert there and saves branching on includeDetails. Type is the + // KIKIEHKBHNM ordinal — we only ever store free-form tags, so 0. + ["Tags"] = extras.Tags.Select(t => new { Tag = t, Type = 0 }).ToList(), }; + /// JPIKCIGABBI — {PlayerEventResponseId, PlayerEventId, + /// PlayerId(Int32), CreatedAt, Type}, keys at + /// DDDLCGDEPNH.txt:395-502, types off the getters in + /// JPIKCIGABBI.txt. + private static object ResponseWire(PlayerEventResponseEntity r) => new + { + PlayerEventResponseId = r.Id, + PlayerEventId = r.EventId, + PlayerId = (int)r.PlayerId, + r.CreatedAt, + Type = r.Response, + }; + + /// PHHAKLPGNGC — the wrapper every v2 mutation deserialises. + /// + private static object Wrapper(PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new + { + PlayerEvent = ToWire(ev, extras, attendeeCount), + Result = 0, // BNCFHOOCHAI success + TagModifyResult = new { Result = 0, Tags = extras.Tags }, + }; + + private async Task WrapperAsync(PlayerEventEntity ev) + { + var extras = (await LoadExtrasAsync(new[] { ev }))[ev.Id]; + var counts = await AttendeeCountsAsync(new[] { ev.Id }); + return Wrapper(ev, extras, counts.GetValueOrDefault(ev.Id)); + } + + private async Task>> ToWireManyAsync(IReadOnlyList rows) + { + if (rows.Count == 0) return []; + var extras = await LoadExtrasAsync(rows); + var counts = await AttendeeCountsAsync(rows.Select(r => r.Id).ToList()); + return rows + .Select(r => ToWire(r, extras[r.Id], counts.GetValueOrDefault(r.Id))) + .ToList(); + } + + private async Task>> ToWireMapAsync( + IReadOnlyList rows) + { + var wire = await ToWireManyAsync(rows); + var map = new Dictionary>(); + for (var i = 0; i < rows.Count; i++) map[rows[i].Id] = wire[i]; + return map; + } + + private async Task> ToWireOneAsync(PlayerEventEntity ev) + => (await ToWireManyAsync(new[] { ev }))[0]; + + private async Task> LoadExtrasAsync(IReadOnlyCollection events) + { + var map = new Dictionary(); + foreach (var ev in events) map[ev.Id] = new EventExtras(); + if (map.Count == 0) return map; + + var keys = map.Keys.Select(ExtrasKey).ToList(); + var rows = await db.PlayerSettings + .AsNoTracking() + .Where(s => keys.Contains(s.Key)) + .Select(s => new { s.Key, s.Value }) + .ToListAsync(); + + foreach (var row in rows) + { + if (EventIdFromKey(row.Key) is not long id || !map.ContainsKey(id)) continue; + var parsed = DeserializeExtras(row.Value); + if (parsed is not null) map[id] = parsed; + } + return map; + } + + private static long? EventIdFromKey(string key) + { + var parts = key.Split(':'); + return parts.Length >= 3 && long.TryParse(parts[1], out var id) ? id : null; + } + + private static EventExtras? DeserializeExtras(string json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + try + { + return JsonSerializer.Deserialize(json); + } + catch (JsonException) + { + return null; + } + } + + private async Task SaveExtrasAsync(PlayerEventEntity evt, EventExtras extras) + { + // PlayerSettingEntity.Value is MaxLength(1024); bound the tag list so a + // long one can never truncate the JSON blob into unparseable garbage. + if (extras.Tags.Count > 12) extras.Tags = extras.Tags.Take(12).ToList(); + extras.Tags = extras.Tags.Select(t => Clamp(t, 32)).ToList(); + + await UpsertSettingAsync(evt.CreatorPlayerId, ExtrasKey(evt.Id), + JsonSerializer.Serialize(extras)); + // Flat index row so "events for club X" stays one indexed lookup + // instead of a scan through the JSON blobs. + await UpsertSettingAsync(evt.CreatorPlayerId, ClubKey(evt.Id), + extras.ClubId?.ToString(CultureInfo.InvariantCulture) ?? string.Empty); + + evt.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + } + + private async Task UpsertSettingAsync(long playerId, string key, string value) + { + 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 = value, + }); + } + else + { + row.Value = value; + } + } + + private async Task> AttendeeCountsAsync(IReadOnlyCollection eventIds) + { + if (eventIds.Count == 0) return new Dictionary(); + var ids = eventIds.ToList(); + var rows = await db.PlayerEventResponses + .Where(r => ids.Contains(r.EventId) && r.Response != NotGoingResponse) + .GroupBy(r => r.EventId) + .Select(g => new { EventId = g.Key, Count = g.Count() }) + .ToListAsync(); + return rows.ToDictionary(r => r.EventId, r => r.Count); + } + + // ── Query shaping ──────────────────────────────────────────────────── + + /// 'scheduleFilter' (BJBLPLKMLBE) is an enum ordinal. Only the + /// query-key literal survives in the ISIL — enums have no method bodies to + /// disassemble — so the member order below is UNVERIFIED. Ordinal 0 (and + /// absent) reproduces the previous behaviour exactly, and a wrong guess for + /// the others still returns a valid, non-empty event list rather than an + /// error. + private static IQueryable ApplyScheduleFilter( + IQueryable rows, int? scheduleFilter) + { + var now = DateTime.UtcNow; + return scheduleFilter switch + { + 1 => rows.Where(e => e.StartsAt <= now && e.EndsAt > now), + 2 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(1)), + 3 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(7)), + _ => rows.Where(e => e.EndsAt > now), + }; + } + + /// 'sort' (PJBLEKMMACM) — same UNVERIFIED-ordinal caveat as + /// ; 0/absent keeps soonest-first. + private static IQueryable ApplySort( + IQueryable rows, int? sort) => sort switch + { + 1 => rows.OrderByDescending(e => e.CreatedAt), + 2 => rows.OrderBy(e => e.EndsAt), + _ => rows.OrderBy(e => e.StartsAt), + }; + + /// Events attached to any of . Explicit + /// ClubId (persisted since the v2 create/edit body carries it) wins; when + /// nothing carries one we fall back to the historical heuristic of "events + /// in rooms owned by club members" so pre-existing data still populates the + /// tab. + private async Task> ClubEventsAsync( + IReadOnlyCollection clubIds, int skip, int take) + { + var explicitIds = await EventIdsForClubsAsync(clubIds); + IQueryable q; + if (explicitIds.Count > 0) + { + q = db.PlayerEvents.Where(e => explicitIds.Contains(e.Id) && e.EndsAt > DateTime.UtcNow); + } + else + { + var roomIds = await ClubRoomIdsAsync(clubIds); + if (roomIds.Count == 0) return []; + q = db.PlayerEvents.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow); + } + + return await q.OrderBy(e => e.StartsAt).Skip(skip).Take(take).ToListAsync(); + } + + private async Task> EventIdsForClubsAsync(IReadOnlyCollection clubIds) + { + if (clubIds.Count == 0) return []; + var wanted = clubIds.Select(c => c.ToString(CultureInfo.InvariantCulture)).ToList(); + var keys = await db.PlayerSettings + .AsNoTracking() + .Where(s => wanted.Contains(s.Value) + && s.Key.StartsWith("playerevent:") + && s.Key.EndsWith(":club")) + .Select(s => s.Key) + .ToListAsync(); + + return keys + .Select(EventIdFromKey) + .Where(id => id is not null) + .Select(id => id!.Value) + .Distinct() + .ToList(); + } + + private async Task> ClubRoomIdsAsync(IReadOnlyCollection clubIds) + { + var memberships = db.ClubMemberships.AsQueryable(); + if (clubIds.Count > 0) + { + var ids = clubIds.ToList(); + memberships = memberships.Where(m => ids.Contains(m.ClubId)); + } + + var memberIds = await memberships.Select(m => m.PlayerId).Distinct().ToListAsync(); + if (memberIds.Count == 0) return []; + return await db.Rooms + .Where(r => memberIds.Contains(r.CreatorPlayerId)) + .Select(r => r.Id) + .ToListAsync(); + } + + // ── Create / edit (v2) ─────────────────────────────────────────────── + + /// POST api/playerevents/v2 — the 2023 create-event flow. + /// KKMMBCDGFFN posts BIEFKAOABMP as a RawJsonForm body (verb 2 at + /// CBKANFIOBCF.txt:2390) whose 14 keys are + /// RoomId, SubRoomId, ClubId, Name, Description, Tags, ImageName, + /// StartTime, EndTime, Accessibility, IsMultiInstance, + /// SupportMultiInstanceRoomChat, DefaultBroadcastPermissions, + /// CanRequestBroadcastPermissions (ACKNGEEHHAE.txt:935-1234), + /// and reads the PHHAKLPGNGC wrapper back. Only [HttpGet] was + /// registered on this path, so creating an event 405'd. + [HttpPost("api/playerevents/v2")] + public async Task CreateV2() + { + var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim() ?? string.Empty; + if (name.Length == 0) return BadRequest("missing_name"); + + var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt") ?? DateTime.UtcNow; + var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt") ?? start.AddHours(1); + var description = (await ReadStringFieldAsync("Description", "description"))?.Trim() ?? string.Empty; + + var evt = new PlayerEventEntity + { + CreatorPlayerId = Me, + RoomId = await ReadLongFieldAsync("RoomId", "roomId") ?? 0, + Title = Clamp(name, 128), + Description = Clamp(description, 2000), + StartsAt = start, + EndsAt = end > start ? end : start.AddHours(1), + }; + db.PlayerEvents.Add(evt); + await db.SaveChangesAsync(); + + var extras = new EventExtras(); + await ApplyBodyToExtrasAsync(extras); + await SaveExtrasAsync(evt, extras); + return Ok(await WrapperAsync(evt)); + } + + /// POST api/playerevents/v2/{eventId} — edit-event save. + /// IOEJKIPJFOI posts the same BIEFKAOABMP body with verb 2 + /// (CBKANFIOBCF.txt:2595, route literal :2579); the path was + /// GET-only, so editing 405'd. + [HttpPost("api/playerevents/v2/{eventId:long}")] + public async Task EditV2(long eventId) + { + var evt = await GetOwnedEventAsync(eventId); + if (evt is null) return NotFound(); + + var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim(); + if (!string.IsNullOrEmpty(name)) evt.Title = Clamp(name, 128); + + var description = (await ReadStringFieldAsync("Description", "description"))?.Trim(); + if (description is not null) evt.Description = Clamp(description, 2000); + + var roomId = await ReadLongFieldAsync("RoomId", "roomId"); + if (roomId is long room && room > 0) evt.RoomId = room; + + var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt"); + var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt"); + if (start is DateTime s) evt.StartsAt = s; + if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1); + + var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; + await ApplyBodyToExtrasAsync(extras); + await SaveExtrasAsync(evt, extras); + await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated); + return Ok(await WrapperAsync(evt)); + } + + /// Copies whatever subset of the BIEFKAOABMP body is present onto + /// . Absent keys are left alone so the same helper + /// serves both create and edit. + private async Task ApplyBodyToExtrasAsync(EventExtras extras) + { + var fields = await ReadBodyAsync(); + + if (fields.Fields.ContainsKey("SubRoomId")) + extras.SubRoomId = await ReadLongFieldAsync("SubRoomId", "subRoomId"); + if (fields.Fields.ContainsKey("ClubId")) + extras.ClubId = await ReadLongFieldAsync("ClubId", "clubId"); + + var imageName = await ReadStringFieldAsync("ImageName", "imageName"); + if (imageName is not null) extras.ImageName = Clamp(imageName.Trim(), 128); + + if (await ReadIntFieldAsync("Accessibility", "accessibility") is int accessibility) + extras.Accessibility = accessibility; + if (await ReadBoolFieldAsync("IsMultiInstance", "isMultiInstance") is bool multi) + extras.IsMultiInstance = multi; + if (await ReadBoolFieldAsync("SupportMultiInstanceRoomChat", "supportMultiInstanceRoomChat", + "supportsMultiInstanceRoomChat") is bool chat) + extras.SupportMultiInstanceRoomChat = chat; + if (await ReadIntFieldAsync("DefaultBroadcastPermissions", "defaultBroadcastPermissions") is int def) + extras.DefaultBroadcastPermissions = def; + if (await ReadIntFieldAsync("CanRequestBroadcastPermissions", "canRequestBroadcastPermissions") is int req) + extras.CanRequestBroadcastPermissions = req; + + var tags = await ReadStringListFieldAsync("Tags", "tags"); + if (tags is not null) extras.Tags = tags; + } + + // ── Create / RSVP (v1, shared with the 2020 client) ────────────────── + public sealed record CreateEventRequest( string Title, string? Description, long RoomId, DateTime StartsAt, DateTime EndsAt, int Capacity); @@ -295,11 +876,7 @@ public class PlayerEventsController( }; db.PlayerEvents.Add(ev); await db.SaveChangesAsync(); - return Ok(new - { - ev.Id, ev.Title, ev.Description, ev.RoomId, - ev.StartsAt, ev.EndsAt, ev.Capacity, - }); + return Ok(await ToWireOneAsync(ev)); } public sealed record RsvpRequest(int Response); @@ -328,25 +905,45 @@ public class PlayerEventsController( await notifications.NotifyAsync(ev.CreatorPlayerId, PushNotificationId.PlayerEventResponseChanged, new { ev.Id, From = Me, body.Response }); + // Left as the historical shape on purpose: this route is 2020-only + // (the 2023 client GETs .../responses and POSTs v1/respond instead), + // so there is no 2023 DTO to match here. return Ok(new { row.EventId, row.PlayerId, row.Response }); } - /// POST api/playerevents/v1/respond — set the - /// caller's RSVP state for an event. Response is the - /// PlayerEventResponseType enum (0=Going, 1=Maybe, 2=Pass). - /// Idempotent — re-responding overwrites. - /// - /// Wire return is a BARE int (DGOPHENCPOC enum value: - /// 0=Success, plus various error codes). Returning an object - /// here throws InvalidCastException on the 2020.12 deserialiser - /// — verified at recroom-2020-client-response-contracts.md - /// under the playerevents/v1/respond section. + /// POST api/playerevents/v1/{eventId}/responses — + /// alternate RSVP route the 2020 watch uses for the response-list + /// flow (older / sub-room view). Body is the same RsvpRequest + /// as v1/{id}/rsvp; behaves identically. The 2023 client GETs this + /// path instead — see . + [HttpPost("api/playerevents/v1/{eventId:long}/responses")] + public Task RespondList(long eventId, [FromBody] RsvpRequest body) + => Rsvp(eventId, body); + + /// POST api/playerevents/v1/respond — set the caller's RSVP + /// state. Two client generations share this route with different + /// contracts: + /// * 2020.12 posts a form and reads a BARE int (DGOPHENCPOC); returning + /// an object throws InvalidCastException there — see + /// recroom-2020-client-response-contracts.md. + /// * 2023-03 posts a RawJsonForm body {PlayerEventId, Type} + /// (NKIFBKJALEJ, keys at GFHNJMMBHFD.txt:203-238; body build at + /// CBKANFIOBCF_NestedType_NFCEOKLHDAG.txt:81-112) and reads + /// CEKABGOIOAF, an OBJECT {Result:int} + /// (GJJGDENPOAP.txt:139-150). + /// Branch the response shape on the body encoding the caller actually + /// used — that is the only signal that distinguishes them. [HttpPost("api/playerevents/v1/respond")] - public async Task RespondForm( - [FromForm(Name = "EventId")] long? eventId, - [FromForm(Name = "Response")] int response = 0) + public async Task RespondForm() { - if (eventId is not long evt || evt <= 0) return Ok(2 /*NoSuchEvent*/); + var legacyForm = Request.HasFormContentType; + var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId"); + if (eventId is not long evt || evt <= 0) return RespondResult(legacyForm, 2 /*NoSuchEvent*/); + + var response = await ReadIntFieldAsync("Type", "type", "Response", "response") ?? 0; + if (!await db.PlayerEvents.AnyAsync(e => e.Id == evt)) + return RespondResult(legacyForm, 2 /*NoSuchEvent*/); + var existing = await db.PlayerEventResponses .FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt); if (existing is null) @@ -354,35 +951,65 @@ public class PlayerEventsController( else existing.Response = response; await db.SaveChangesAsync(); - return Ok(0 /*Success*/); + return RespondResult(legacyForm, 0 /*Success*/); } - /// POST api/playerevents/v1/deleteResponse — drop - /// the caller's RSVP row. Same bare-int wire shape as - /// . + /// POST api/playerevents/v1/deleteResponse — drop the + /// caller's RSVP row. Same two-generation split as + /// . The 2023 body is + /// JsonUtility.ToJson(RecNet.Events.DeleteResponseRequest) + /// (CBKANFIOBCF_NestedType_MDJMHFHPOBO.txt:65-78) whose single field + /// is name-preserved: public long PlayerEventId + /// (2023.06.21 dump.cs:1291301). [HttpPost("api/playerevents/v1/deleteResponse")] - public async Task DeleteResponseForm([FromForm(Name = "EventId")] long? eventId) + public async Task DeleteResponseForm() { - if (eventId is not long evt) return Ok(2 /*NoSuchEvent*/); + var legacyForm = Request.HasFormContentType; + var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId"); + if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/); + await db.PlayerEventResponses .Where(r => r.PlayerId == Me && r.EventId == evt) .ExecuteDeleteAsync(); - return Ok(0 /*Success*/); + return RespondResult(legacyForm, 0 /*Success*/); } - /// DELETE api/playerevents/v2/delete/{id} — hard - /// delete an event the caller created. + private IActionResult RespondResult(bool legacyForm, int code) + => legacyForm ? Ok(code) : Ok(new { Result = code }); + + /// DELETE/POST api/playerevents/v2/delete/{id} — hard + /// delete an event the caller created. GJMCEDALKJO dispatches with verb 2 + /// (CBKANFIOBCF.txt:4166, route literal :4156) and reads the + /// PHHAKLPGNGC wrapper, so POST plus the wrapper body are both required; + /// DELETE stays for the 2020 client. + [HttpPost("api/playerevents/v2/delete/{id:long}")] [HttpDelete("api/playerevents/v2/delete/{id:long}")] public async Task DeleteByPath(long id) { var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id && e.CreatorPlayerId == Me); if (evt is null) return NotFound(); + + // Snapshot the wire shape before the rows go away — the wrapper still + // has to echo the event that was deleted. + var extras = (await LoadExtrasAsync(new[] { evt }))[id]; + var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id); + db.PlayerEvents.Remove(evt); await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync(); + await db.PlayerSettings + .Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id)) + .ExecuteDeleteAsync(); await db.SaveChangesAsync(); - return Ok(new { success = true, error = "" }); + return Ok(Wrapper(evt, extras, attendees)); } + // ── Field mutations (v2) ───────────────────────────────────────────── + // Every one of these is a PUT (verb 3, e.g. CBKANFIOBCF.txt:2744) whose + // fields arrive as x-www-form-urlencoded, and every one returns the + // PHHAKLPGNGC wrapper — a flat event body leaves the client's PlayerEvent + // null right after a successful save. POST is kept alongside for the 2020 + // client. + [HttpPost("api/playerevents/v2/{eventId:long}/name")] [HttpPut("api/playerevents/v2/{eventId:long}/name")] public async Task UpdateName(long eventId) @@ -391,10 +1018,10 @@ public class PlayerEventsController( if (evt is null) return NotFound(); var value = await ReadStringFieldAsync("name", "Name", "value"); if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name"); - evt.Title = value.Trim()[..Math.Min(value.Trim().Length, 128)]; + evt.Title = Clamp(value.Trim(), 128); evt.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - return Ok(ToWire(evt)); + return Ok(await WrapperAsync(evt)); } [HttpPost("api/playerevents/v2/{eventId:long}/description")] @@ -404,12 +1031,17 @@ public class PlayerEventsController( var evt = await GetOwnedEventAsync(eventId); if (evt is null) return NotFound(); var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty; - evt.Description = value.Trim()[..Math.Min(value.Trim().Length, 2000)]; + evt.Description = Clamp(value.Trim(), 2000); evt.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - return Ok(ToWire(evt)); + return Ok(await WrapperAsync(evt)); } + /// PUT api/playerevents/v2/{id}/room — FPPNHCMPAKK sends + /// roomId AND an optional subRoomId + /// (CBKANFIOBCF_NestedType_AKPPDNLCACI.txt:88,101); dropping the + /// latter meant the event never pointed at the sub-room the host picked. + /// [HttpPost("api/playerevents/v2/{eventId:long}/room")] [HttpPut("api/playerevents/v2/{eventId:long}/room")] public async Task UpdateRoom(long eventId) @@ -420,9 +1052,11 @@ public class PlayerEventsController( if (roomId is not long id || id <= 0) return BadRequest("missing_room"); if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound("room_not_found"); evt.RoomId = id; - evt.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(); - return Ok(ToWire(evt)); + + var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; + extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId"); + await SaveExtrasAsync(evt, extras); + return Ok(await WrapperAsync(evt)); } [HttpPost("api/playerevents/v2/{eventId:long}/time")] @@ -438,7 +1072,7 @@ public class PlayerEventsController( if (start is null && end is null) return BadRequest("missing_time"); evt.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - return Ok(ToWire(evt)); + return Ok(await WrapperAsync(evt)); } [HttpPost("api/playerevents/v2/{eventId:long}/image")] @@ -448,8 +1082,10 @@ public class PlayerEventsController( var evt = await GetOwnedEventAsync(eventId); if (evt is null) return NotFound(); var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty; - await SetEventSettingAsync(evt, "image", value.Trim()); - return Ok(ToWire(evt)); + var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; + extras.ImageName = Clamp(value.Trim(), 128); + await SaveExtrasAsync(evt, extras); + return Ok(await WrapperAsync(evt)); } [HttpPost("api/playerevents/v2/{eventId:long}/accessibility")] @@ -460,44 +1096,92 @@ public class PlayerEventsController( if (evt is null) return NotFound(); var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value"); if (value is not int accessibility) return BadRequest("missing_accessibility"); - await SetEventSettingAsync(evt, "accessibility", accessibility.ToString()); - return Ok(ToWire(evt)); + var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; + extras.Accessibility = accessibility; + await SaveExtrasAsync(evt, extras); + return Ok(await WrapperAsync(evt)); } + /// PUT api/playerevents/v2/{id}/tags. JHLMDMGELFD does NOT + /// send form fields — it serialises List<String> straight into + /// a RawJsonForm body, i.e. a BARE JSON ARRAY + /// (CBKANFIOBCF_NestedType_PNFGKCKCGEL.txt:63-81). The old field + /// reader only looked inside JSON objects, so every save stored an empty + /// string and silently cleared the tags. The wrapper's + /// TagModifyResult.Tags is what the client re-reads afterwards. + /// [HttpPost("api/playerevents/v2/{eventId:long}/tags")] [HttpPut("api/playerevents/v2/{eventId:long}/tags")] public async Task UpdateTags(long eventId) { var evt = await GetOwnedEventAsync(eventId); if (evt is null) return NotFound(); - var value = await ReadStringFieldAsync("tags", "Tags", "value") ?? string.Empty; - await SetEventSettingAsync(evt, "tags", value.Trim()); - return Ok(ToWire(evt)); + var tags = await ReadStringListFieldAsync("tags", "Tags", "value") ?? new List(); + var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; + extras.Tags = tags; + await SaveExtrasAsync(evt, extras); + return Ok(await WrapperAsync(evt)); } + /// PUT api/playerevents/v2/{id}/multiinstance. IEGDKKAKHBD + /// sends FOUR form fields — isMultiInstance, + /// supportsMultiInstanceRoomChat, defaultBroadcastPermissions, + /// canRequestBroadcastPermissions + /// (CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:122-161). The handler used + /// to look for a field named multiInstance, which the client never + /// sends, so the toggle always stored false and the other three were + /// dropped. Note the request spells it "supports…" while the response key + /// is "Support…" — both spellings are accepted here. [HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")] [HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")] public async Task UpdateMultiInstance(long eventId) { var evt = await GetOwnedEventAsync(eventId); if (evt is null) return NotFound(); - var value = await ReadBoolFieldAsync("multiInstance", "MultiInstance", "value"); - await SetEventSettingAsync(evt, "multiinstance", (value ?? false) ? "true" : "false"); - return Ok(ToWire(evt)); + var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; + + extras.IsMultiInstance = + await ReadBoolFieldAsync("isMultiInstance", "IsMultiInstance", "multiInstance", "value") ?? false; + extras.SupportMultiInstanceRoomChat = + await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat", + "SupportMultiInstanceRoomChat") ?? false; + extras.DefaultBroadcastPermissions = + await ReadIntFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions") ?? 0; + extras.CanRequestBroadcastPermissions = + await ReadIntFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions") ?? 0; + + await SaveExtrasAsync(evt, extras); + return Ok(await WrapperAsync(evt)); } + /// PUT api/playerevents/v2/{id}/club. JLHJHGANFDM takes a + /// Nullable<Int64> and boxes it into the single form field + /// clubId (CBKANFIOBCF_NestedType_DJLDOCJBFPO.txt:60-71) — a + /// null therefore arrives as an absent/empty field, which is the client's + /// only way to DETACH the club. Rejecting that with 400 made detaching + /// impossible. [HttpPost("api/playerevents/v2/{eventId:long}/club")] [HttpPut("api/playerevents/v2/{eventId:long}/club")] public async Task UpdateClub(long eventId) { var evt = await GetOwnedEventAsync(eventId); if (evt is null) return NotFound(); + var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; + var clubId = await ReadLongFieldAsync("clubId", "ClubId", "value"); - if (clubId is not long id || id <= 0) return BadRequest("missing_club"); - if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me)) - return Forbid(); - await SetEventSettingAsync(evt, "club", id.ToString()); - return Ok(ToWire(evt)); + if (clubId is long id && id > 0) + { + if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me)) + return Forbid(); + extras.ClubId = id; + } + else + { + extras.ClubId = null; + } + + await SaveExtrasAsync(evt, extras); + return Ok(await WrapperAsync(evt)); } [HttpDelete("api/playerevents/v1/{eventId:long}")] @@ -512,10 +1196,13 @@ public class PlayerEventsController( var responses = db.PlayerEventResponses.Where(r => r.EventId == eventId); db.PlayerEventResponses.RemoveRange(responses); await db.SaveChangesAsync(); + await db.PlayerSettings + .Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId)) + .ExecuteDeleteAsync(); return Ok(); } - // ── Bulk-invite (Phase 8) ──────────────────────────────────────────── + // ── Bulk-invite ────────────────────────────────────────────────────── public sealed class BulkInviteRequest { @@ -523,12 +1210,17 @@ public class PlayerEventsController( public List? InvitedPlayerIds { get; set; } } - /// POST /api/playerevents/v1/bulkInvite — wire - /// type per agent ISIL extraction: BulkInviteRequest{PlayerEventId, - /// InvitedPlayerIds:List<int>}; response - /// BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}. - /// We insert one MessageEntity per recipient so the watch's inbox - /// shows the invite, and push a per-recipient notification. + /// POST /api/playerevents/v1/bulkInvite. Request is + /// JsonUtility.ToJson(RecNet.Events.BulkInviteRequest) — + /// name-preserved fields PlayerEventId / InvitedPlayerIds + /// (2023.06.21 dump.cs:1288323-1288324, body build at + /// CBKANFIOBCF_NestedType_MIKAHKKCIEE.txt:69-96). Response is + /// IDELKAJIOLI {FailedInvites, Result} + /// (GAODOHODBDE.txt:203-238) where each failed entry is + /// {InvitedPlayerId, Result} (MOJLGDKFECO.txt:203-238) — the + /// entries used to be {PlayerId, Error} and deserialised as all + /// defaults. Error paths return the same JSON shape rather than a + /// NotFound()/Forbid() body the client cannot parse. [HttpPost("api/playerevents/v1/bulkInvite")] public async Task BulkInvite([FromBody] BulkInviteRequest req) { @@ -536,8 +1228,11 @@ public class PlayerEventsController( return Ok(new { FailedInvites = Array.Empty(), Result = 0 }); var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId); - if (ev is null) return NotFound(); - if (ev.CreatorPlayerId != Me) return Forbid(); + // BNCFHOOCHAI's member order isn't recoverable (enums have no method + // bodies to disassemble); any non-zero code reads as "failed" on the + // client, which is all the invite sheet branches on. + if (ev is null) return Ok(new { FailedInvites = Array.Empty(), Result = 1 }); + if (ev.CreatorPlayerId != Me) return Ok(new { FailedInvites = Array.Empty(), Result = 1 }); var failed = new List(); var sender = Me; @@ -547,7 +1242,7 @@ public class PlayerEventsController( var exists = await db.Players.AnyAsync(p => p.Id == rid); if (!exists) { - failed.Add(new { PlayerId = rid, Error = "unknown player" }); + failed.Add(new { InvitedPlayerId = rid, Result = 1 }); continue; } db.Messages.Add(new MessageEntity @@ -564,45 +1259,47 @@ public class PlayerEventsController( return Ok(new { FailedInvites = failed, Result = 0 }); } - public sealed class BroadcastRequest + /// POST api/playerevents/v1/broadcast — set or clear the + /// room instance an event is being broadcast from. JBHPMONELAO posts + /// RecNet.Events.BroadcastRoomInstanceRequest + /// (CBKANFIOBCF_NestedType_EHHMGKFGGCC.txt:81) whose JSON keys are + /// PlayerEventId and the nullable BroadcastRoomInstanceId + /// (EOFEKNIHNNC.txt:215-258), and reads the PHHAKLPGNGC wrapper. + /// This used to be implemented as a text-message blast to every RSVP, + /// which both spammed attendees and returned an unparseable body. + [HttpPost("api/playerevents/v1/broadcast")] + public async Task Broadcast() { - public long PlayerEventId { get; set; } - public string? Message { get; set; } + var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId"); + if (eventId is not long id) return BadRequest("missing_event"); + var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id); + if (evt is null) return NotFound(); + if (evt.CreatorPlayerId != Me) return Forbid(); + + var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id]; + extras.BroadcastingRoomInstanceId = await ReadLongFieldAsync( + "BroadcastRoomInstanceId", "broadcastRoomInstanceId", + "BroadcastingRoomInstanceId", "broadcastingRoomInstanceId"); + await SaveExtrasAsync(evt, extras); + + // Attendees' event cards key off BroadcastingRoomInstanceId, so nudge + // them to re-fetch instead of sending a chat message. + await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged); + return Ok(await WrapperAsync(evt)); } - [HttpPost("api/playerevents/v1/broadcast")] - public async Task Broadcast([FromBody] BroadcastRequest req) + private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id) { - var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId); - if (ev is null) return NotFound(); - if (ev.CreatorPlayerId != Me) return Forbid(); - var recipients = await db.PlayerEventResponses - .Where(r => r.EventId == ev.Id && r.Response != 2) + .Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse) .Select(r => r.PlayerId) .Distinct() .ToListAsync(); - var body = string.IsNullOrWhiteSpace(req.Message) - ? $"Update for '{ev.Title}'" - : req.Message.Trim(); - foreach (var playerId in recipients.Where(id => id != Me)) - { - db.Messages.Add(new MessageEntity - { - SenderPlayerId = Me, - RecipientPlayerId = playerId, - Body = body, - }); - await notifications.NotifyAsync(playerId, - PushNotificationId.PlayerEventResponseChanged, - new { ev.Id, ev.Title, Message = body }); - } - - await db.SaveChangesAsync(); - return Ok(new { Success = true, Sent = recipients.Count }); + foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId)) + await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title }); } - // ── Report (Phase 8) ───────────────────────────────────────────────── + // ── Report ─────────────────────────────────────────────────────────── public sealed class PlayerEventReportRequest { @@ -611,174 +1308,6 @@ public class PlayerEventsController( public string? Details { get; set; } } - private async Task GetOwnedEventAsync(long eventId) - => await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me); - - private async Task> ReadEventIdsAsync() - { - var ids = new List(); - - foreach (var value in Request.Query.SelectMany(q => q.Value)) - AddDelimitedIds(ids, value); - - if (Request.HasFormContentType) - { - var form = await Request.ReadFormAsync(); - foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" }) - foreach (var value in form[key]) - AddDelimitedIds(ids, value); - } - else if ((Request.ContentLength ?? 0) > 0 - && Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) - { - try - { - using var doc = await JsonDocument.ParseAsync(Request.Body); - if (doc.RootElement.ValueKind == JsonValueKind.Object) - { - foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" }) - { - if (doc.RootElement.TryGetProperty(key, out var prop)) - AddJsonIds(ids, prop); - } - } - else - { - AddJsonIds(ids, doc.RootElement); - } - } - catch (JsonException) - { - } - } - - return ids.Distinct().Take(200).ToList(); - } - - private static void AddDelimitedIds(List ids, string? value) - { - foreach (var part in (value ?? string.Empty) - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - if (long.TryParse(part, out var id) && id > 0) ids.Add(id); - } - } - - private static void AddJsonIds(List ids, JsonElement element) - { - switch (element.ValueKind) - { - case JsonValueKind.Array: - foreach (var item in element.EnumerateArray()) - AddJsonIds(ids, item); - break; - case JsonValueKind.Number: - if (element.TryGetInt64(out var id) && id > 0) ids.Add(id); - break; - case JsonValueKind.String: - AddDelimitedIds(ids, element.GetString()); - break; - } - } - - private async Task ReadStringFieldAsync(params string[] names) - { - var fields = await ReadRequestFieldsAsync(); - foreach (var name in names) - if (fields.TryGetValue(name, out var value)) - return value; - return null; - } - - private async Task ReadLongFieldAsync(params string[] names) - { - var value = await ReadStringFieldAsync(names); - return long.TryParse(value, out var parsed) ? parsed : null; - } - - private async Task ReadIntFieldAsync(params string[] names) - { - var value = await ReadStringFieldAsync(names); - return int.TryParse(value, out var parsed) ? parsed : null; - } - - private async Task ReadBoolFieldAsync(params string[] names) - { - var value = await ReadStringFieldAsync(names); - return bool.TryParse(value, out var parsed) ? parsed : null; - } - - private async Task ReadDateFieldAsync(params string[] names) - { - var value = await ReadStringFieldAsync(names); - return DateTime.TryParse(value, out var parsed) ? parsed : null; - } - - private async Task> ReadRequestFieldsAsync() - { - const string itemKey = "__playerevent_fields"; - if (HttpContext.Items.TryGetValue(itemKey, out var cached) - && cached is Dictionary existing) - { - return existing; - } - - var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var pair in Request.Query) - fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; - - if (Request.HasFormContentType) - { - var form = await Request.ReadFormAsync(); - foreach (var pair in form) - fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; - } - else if ((Request.ContentLength ?? 0) > 0 - && Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) - { - try - { - using var doc = await JsonDocument.ParseAsync(Request.Body); - if (doc.RootElement.ValueKind == JsonValueKind.Object) - { - foreach (var prop in doc.RootElement.EnumerateObject()) - fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String - ? prop.Value.GetString() ?? string.Empty - : prop.Value.GetRawText(); - } - } - catch (JsonException) - { - } - } - - HttpContext.Items[itemKey] = fields; - return fields; - } - - private async Task SetEventSettingAsync(PlayerEventEntity evt, string key, string value) - { - var settingKey = $"playerevent:{evt.Id}:{key}"; - var row = await db.PlayerSettings - .FirstOrDefaultAsync(s => s.PlayerId == evt.CreatorPlayerId && s.Key == settingKey); - if (row is null) - { - db.PlayerSettings.Add(new PlayerSettingEntity - { - PlayerId = evt.CreatorPlayerId, - Key = settingKey, - Value = value, - }); - } - else - { - row.Value = value; - } - - evt.UpdatedAt = DateTime.UtcNow; - await db.SaveChangesAsync(); - } - /// POST api/playerevents/v1/report — file a /// moderation report against an event. Wire response is /// KLAMKCBENEA{Success, Message}; returning a bare @@ -800,4 +1329,213 @@ public class PlayerEventsController( await db.SaveChangesAsync(); return Ok(new { Success = true, Message = string.Empty }); } + + // ── Request parsing ────────────────────────────────────────────────── + + private static string Clamp(string value, int max) + => value.Length <= max ? value : value[..max]; + + private async Task GetOwnedEventAsync(long eventId) + => await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me); + + private async Task> ReadEventIdsAsync() + { + var ids = new List(); + + foreach (var value in Request.Query.SelectMany(q => q.Value)) + AddDelimitedIds(ids, value); + + var body = await ReadBodyAsync(); + foreach (var key in new[] { "playerEventIds", "eventIds", "ids" }) + { + if (body.Repeated.TryGetValue(key, out var values)) + foreach (var value in values) AddDelimitedIds(ids, value); + } + if (body.RootArray is not null) + foreach (var value in body.RootArray) AddDelimitedIds(ids, value); + + return ids.Distinct().Take(200).ToList(); + } + + private static void AddDelimitedIds(List ids, string? value) + { + foreach (var part in (value ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (long.TryParse(part, out var id) && id > 0) ids.Add(id); + } + } + + private async Task ReadStringFieldAsync(params string[] names) + { + var body = await ReadBodyAsync(); + foreach (var name in names) + if (body.Fields.TryGetValue(name, out var value)) + return value; + return null; + } + + /// Reads a list-valued field. Handles all three encodings the + /// client uses: a bare JSON array body (the tags PUT), a JSON array nested + /// under a key (the v2 create/edit body's Tags), and repeated / + /// comma-joined form values. + private async Task?> ReadStringListFieldAsync(params string[] names) + { + var body = await ReadBodyAsync(); + if (body.RootArray is not null) return body.RootArray; + + foreach (var name in names) + { + if (body.Repeated.TryGetValue(name, out var repeated) && repeated.Count > 1) + return repeated.Select(v => v.Trim()).Where(v => v.Length > 0).ToList(); + + if (!body.Fields.TryGetValue(name, out var raw)) continue; + raw = raw.Trim(); + if (raw.Length == 0) return new List(); + if (raw.StartsWith('[')) + { + var parsed = ParseJsonStringArray(raw); + if (parsed is not null) return parsed; + } + return raw + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + } + return null; + } + + private static List? ParseJsonStringArray(string raw) + { + try + { + using var doc = JsonDocument.Parse(raw); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return null; + return ElementToStrings(doc.RootElement); + } + catch (JsonException) + { + return null; + } + } + + private static List ElementToStrings(JsonElement array) + { + var list = new List(); + foreach (var item in array.EnumerateArray()) + { + var value = item.ValueKind == JsonValueKind.String ? item.GetString() : item.GetRawText(); + if (!string.IsNullOrWhiteSpace(value)) list.Add(value.Trim()); + } + return list; + } + + private async Task ReadLongFieldAsync(params string[] names) + { + var value = await ReadStringFieldAsync(names); + return long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : null; + } + + private async Task ReadIntFieldAsync(params string[] names) + { + var value = await ReadStringFieldAsync(names); + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : null; + } + + private async Task ReadBoolFieldAsync(params string[] names) + { + var value = await ReadStringFieldAsync(names); + if (bool.TryParse(value, out var parsed)) return parsed; + // Form-encoded booleans arrive as "True"/"False" from the client's + // boxed-bool ToString, but a JSON body may carry 1/0. + return value switch { "1" => true, "0" => false, _ => null }; + } + + private async Task ReadDateFieldAsync(params string[] names) + { + var value = await ReadStringFieldAsync(names); + // The client always sends UTC (Utf8Json ISO-8601 with a Z, or + // DateTime.ToString() on an already-UTC value). Force the parse to land + // on Kind=Utc so it compares correctly against DateTime.UtcNow. + return DateTime.TryParse(value, CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed) + ? parsed + : null; + } + + /// Everything the request carries, read once and cached — the body + /// stream can only be consumed a single time. + private sealed class ParsedBody + { + public Dictionary Fields { get; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary> Repeated { get; } = new(StringComparer.OrdinalIgnoreCase); + /// Set when the whole body is a bare JSON array — the shape + /// the tags PUT sends. + public List? RootArray { get; set; } + } + + private const string BodyItemKey = "__playerevent_body"; + + private async Task ReadBodyAsync() + { + if (HttpContext.Items.TryGetValue(BodyItemKey, out var cached) && cached is ParsedBody existing) + return existing; + + var body = new ParsedBody(); + foreach (var pair in Request.Query) + { + body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; + body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList(); + } + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var pair in form) + { + body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; + body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList(); + } + } + else if ((Request.ContentLength ?? 0) > 0) + { + // Read the raw text rather than gating on Content-Type: the client's + // RawJsonForm does set application/json, but the JSON path is also + // the fallback for anything else that isn't a form. + using var reader = new StreamReader( + Request.Body, System.Text.Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, leaveOpen: true); + var raw = await reader.ReadToEndAsync(); + try + { + using var doc = JsonDocument.Parse(raw); + switch (doc.RootElement.ValueKind) + { + case JsonValueKind.Object: + foreach (var prop in doc.RootElement.EnumerateObject()) + { + body.Fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String + ? prop.Value.GetString() ?? string.Empty + : prop.Value.GetRawText(); + if (prop.Value.ValueKind == JsonValueKind.Array) + body.Repeated[prop.Name] = ElementToStrings(prop.Value); + } + break; + case JsonValueKind.Array: + body.RootArray = ElementToStrings(doc.RootElement); + break; + } + } + catch (JsonException) + { + // Empty or non-JSON body → query/form values only. + } + } + + HttpContext.Items[BodyItemKey] = body; + return body; + } } diff --git a/DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs b/DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs index 6379b6f..3062512 100644 --- a/DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs +++ b/DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs @@ -24,19 +24,99 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase .ToList()); } + /// Moderator issues a warning against a player. The 2023-03-21 + /// client POSTs this (verb register rdx=2 at + /// RecNet.Runtime/FPIBGPIAOBI.txt:3455) with the form keys + /// WarnedPlayerId, ReportCategory, DisplayReason and + /// ModeratorNote (:3468-3520), matching the issuing method + /// FGLDKEJLAKB<DJMHAFPGLLN> OEJIMKKDMBI(Int32, BDOGOIGCKMK, + /// String, String) (:3102). + /// + /// Only GET was registered here, so every warning the moderator tools + /// tried to create came back 405 and silently failed. + [HttpPost("api/playerwarnings")] + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] + public async Task CreateWarning( + [FromForm(Name = "WarnedPlayerId")] long? warnedPlayerId, + [FromForm(Name = "ReportCategory")] int? reportCategory, + [FromForm(Name = "DisplayReason")] string? displayReason, + [FromForm(Name = "ModeratorNote")] string? moderatorNote) + { + var me = this.RequireCurrentPlayerId(); + + var moderator = await db.Players + .Where(p => p.Id == me) + .Select(p => (bool?)(p.IsAdmin || p.IsDeveloper || p.IsCommunityTeam)) + .FirstOrDefaultAsync(); + if (moderator != true) + return Ok(new { Success = false, Message = "not_authorized" }); + + var target = warnedPlayerId + ?? (long.TryParse(Request.Query["WarnedPlayerId"].FirstOrDefault(), out var q) ? q : 0); + if (target <= 0) return Ok(new { Success = false, Message = "missing_player" }); + if (!await db.Players.AnyAsync(p => p.Id == target)) + return Ok(new { Success = false, Message = "player_not_found" }); + + // Same "warning:{id}|message|category|createdAt|acknowledged" layout the + // GET list handler parses in ToWarningWire. + var reason = (displayReason ?? string.Empty).Trim(); + var note = (moderatorNote ?? string.Empty).Trim(); + var message = string.IsNullOrEmpty(note) ? reason : $"{reason} ({note})"; + var id = DateTime.UtcNow.ToString("yyyyMMddHHmmssfff"); + + db.PlayerSettings.Add(new DorkNet.Server.Data.Entities.PlayerSettingEntity + { + PlayerId = target, + Key = $"warning:{id}", + Value = string.Join('|', + message.Replace('|', '/'), + (reportCategory ?? 0).ToString(), + DateTime.UtcNow.ToString("O"), + "false"), + }); + await db.SaveChangesAsync(); + return Ok(new { Success = true, Message = string.Empty, WarningId = id }); + } + + /// The 2023-03-21 client's acknowledge call takes NO arguments — + /// the issuing method is FGLDKEJLAKB<DJMHAFPGLLN> DOFADJPFBMK() + /// (FPIBGPIAOBI.txt:3524) and it POSTs an empty body (:3590-3592). It means + /// "acknowledge my pending warning". + /// + /// Demanding a warningId made every call 400, so the modal was never marked + /// read and re-appeared on each login. With no id supplied we now + /// acknowledge the newest unacknowledged warning, and acknowledging nothing + /// is a success rather than an error. [HttpPost("api/playerwarnings/acknowledge")] + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] public async Task Acknowledge([FromForm] string? warningId) { var me = this.RequireCurrentPlayerId(); var id = warningId ?? Request.Query["warningId"].FirstOrDefault() ?? Request.Query["id"].FirstOrDefault(); - if (string.IsNullOrWhiteSpace(id)) return BadRequest("missing_warning"); - var row = await db.PlayerSettings - .FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}"); - if (row is null) return NotFound(); + + DorkNet.Server.Data.Entities.PlayerSettingEntity? row; + if (string.IsNullOrWhiteSpace(id)) + { + // Acknowledged state lives inside the packed Value string, so the + // filter has to run client-side after materialising. + var rows = await db.PlayerSettings + .Where(s => s.PlayerId == me && s.Key.StartsWith("warning:")) + .OrderByDescending(s => s.Id) + .ToListAsync(); + row = rows.FirstOrDefault(s => !ToWarningWire(s).Acknowledged); + if (row is null) return Ok(new { Success = true, Message = string.Empty }); + } + else + { + row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}"); + if (row is null) return Ok(new { Success = true, Message = string.Empty }); + } + var parts = row.Value.Split('|'); row.Value = string.Join('|', parts.Take(3).Append("true")); await db.SaveChangesAsync(); - return Ok(new { Success = true, WarningId = id }); + return Ok(new { Success = true, Message = string.Empty, WarningId = row.Key["warning:".Length..] }); } private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row) diff --git a/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs b/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs index 59a23b9..a234504 100644 --- a/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs +++ b/DorkNet.Server/Controllers/API/Players/V2/PlayersController.cs @@ -21,6 +21,9 @@ public class PlayersController(PlayerService playerService, NotificationService return Ok(PlayerService.ToProfile(player)); } + // The 2023 client PUTs this right after setting an email; GET-only meant a + // 405 on every contact-preferences write. + [HttpPut("/api/players/v4/current/contact")] [HttpGet("/api/players/v4/current/contact")] public async Task CurrentContact() { diff --git a/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs b/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs index 9822030..1e5648e 100644 --- a/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs +++ b/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs @@ -37,20 +37,30 @@ public class PlaylistsController( // ── Create / delete ────────────────────────────────────────────── - public sealed class CreatePlaylistRequest - { - public string? Name { get; set; } - public string? Description { get; set; } - public string? ImageName { get; set; } - public string? Tags { get; set; } - } - + /// POST /playlists — create. The 2023-03-21 client posts a + /// form with a single field name (verb 2 at + /// NLDBPDCNNCF.txt:11139-11140, field literal in + /// NLDBPDCNNCF_NestedType_NEECJOENHPI.txt — its only + /// Move rdx, "…" is "name"). The old non-optional + /// [FromBody] parameter made [ApiController] reject the + /// form content type with 415 before the action ran, so creating a + /// playlist from the watch was impossible. Fields are read from form, + /// query or JSON body so JSON callers (admin UI) keep working. [HttpPost("/playlists")] [Authorize] - public async Task Create([FromBody] CreatePlaylistRequest req) + public async Task Create() { - if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest(new { error = "missing_name" }); - var created = await playlists.CreateAsync(Me, req.Name, req.Description, req.ImageName, req.Tags); + var name = await ReadFieldAsync("name", "Name"); + if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { error = "missing_name" }); + + var description = await ReadFieldAsync("description", "Description"); + var imageName = await ReadFieldAsync("imageName", "ImageName"); + var tags = await ReadFieldsAsync("tag", "Tag", "tags", "Tags"); + var tagsCsv = tags.Count > 0 + ? string.Join(',', tags.Distinct(StringComparer.OrdinalIgnoreCase)) + : null; + + var created = await playlists.CreateAsync(Me, name.Trim(), description, imageName, tagsCsv); return await BuildDetailsResponseAsync(created.Id); } @@ -68,61 +78,206 @@ public class PlaylistsController( // ── Per-field mutations (each returns BMFAGMFKODA) ─────────────── - public sealed class StringFieldRequest { public string? Value { get; set; } } - public sealed class IntFieldRequest { public int? Value { get; set; } } - public sealed class BoolFieldRequest { public bool? Value { get; set; } } - public sealed class TagsRequest { public string? Tags { get; set; } public List? TagsList { get; set; } } + // The 2023-03-21 client sends these as form-urlencoded PUTs. A + // non-optional [FromBody] parameter made [ApiController] demand JSON and + // reject every one with 415 before the action ran, so renaming, re-imaging, + // re-tagging and publishing a playlist all failed silently. Values are now + // read from form, query or JSON body, whichever arrived. [HttpPost("/playlists/{playlistId:long}/name")] [HttpPut("/playlists/{playlistId:long}/name")] [Authorize] - public Task PlaylistName(long playlistId, [FromBody] StringFieldRequest req, - [FromForm(Name = "Name")] string? formValue) => - ApplyMutation(playlistId, p => p.Name = (req?.Value ?? formValue ?? p.Name).Trim()); + public async Task PlaylistName(long playlistId) + { + var value = await ReadFieldAsync("name", "Name", "value", "Value"); + return await ApplyMutation(playlistId, p => p.Name = (value ?? p.Name).Trim()); + } [HttpPost("/playlists/{playlistId:long}/description")] [HttpPut("/playlists/{playlistId:long}/description")] [Authorize] - public Task PlaylistDescription(long playlistId, [FromBody] StringFieldRequest req, - [FromForm(Name = "Description")] string? formValue) => - ApplyMutation(playlistId, p => p.Description = req?.Value ?? formValue ?? p.Description); + public async Task PlaylistDescription(long playlistId) + { + var value = await ReadFieldAsync("description", "Description", "value", "Value"); + return await ApplyMutation(playlistId, p => p.Description = value ?? p.Description); + } [HttpPost("/playlists/{playlistId:long}/image")] [HttpPut("/playlists/{playlistId:long}/image")] [Authorize] - public Task PlaylistImage(long playlistId, [FromBody] StringFieldRequest req, - [FromForm(Name = "ImageName")] string? formValue) => - ApplyMutation(playlistId, p => p.ImageName = req?.Value ?? formValue ?? p.ImageName); + public async Task PlaylistImage(long playlistId) + { + var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value"); + return await ApplyMutation(playlistId, p => p.ImageName = value ?? p.ImageName); + } + /// The client sends REPEATED form fields tag and + /// autoTag, not a single CSV Tags value. [HttpPost("/playlists/{playlistId:long}/tags")] [HttpPut("/playlists/{playlistId:long}/tags")] [Authorize] - public Task PlaylistTags(long playlistId, [FromBody] TagsRequest? req, - [FromForm(Name = "Tags")] string? formValue) => - ApplyMutation(playlistId, p => + public async Task PlaylistTags(long playlistId) + { + var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags"); + return await ApplyMutation(playlistId, p => { - var csv = req?.Tags - ?? (req?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null) - ?? formValue - ?? p.TagsCsv; - p.TagsCsv = csv; + if (tags.Count > 0) + p.TagsCsv = string.Join(',', tags.Distinct(StringComparer.OrdinalIgnoreCase)); }); + } - /// Accessibility / visibility / level-voting / restrictions / - /// warning are persisted on the playlist row even though the 2020 - /// PlaylistEntity doesn't have dedicated columns for each — the wire - /// shape always reports the union-entry defaults, so for now the - /// mutation is a no-op acknowledgement that returns the unchanged - /// playlist. Real per-field columns can be added when we have a - /// playlist-settings UI to drive them. [HttpPost("/playlists/{playlistId:long}/accessibility")] + [HttpPut("/playlists/{playlistId:long}/accessibility")] [HttpPost("/playlists/{playlistId:long}/visibility")] - [HttpPost("/playlists/{playlistId:long}/levelvoting")] - [HttpPost("/playlists/{playlistId:long}/restrictions")] - [HttpPost("/playlists/{playlistId:long}/warning")] + [HttpPut("/playlists/{playlistId:long}/visibility")] [Authorize] - public Task PlaylistAck(long playlistId) => - ApplyMutation(playlistId, _ => { /* no-op: field not persisted yet */ }); + public async Task PlaylistAccessibility(long playlistId) + { + var value = await ReadFieldAsync( + "accessibility", "Accessibility", "visibility", "Visibility", "value", "Value"); + return await ApplyMutation(playlistId, p => + { + if (int.TryParse(value, out var v)) p.Accessibility = v; + }); + } + + [HttpPost("/playlists/{playlistId:long}/levelvoting")] + [HttpPut("/playlists/{playlistId:long}/levelvoting")] + [Authorize] + public async Task PlaylistLevelVoting(long playlistId) + { + var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value"); + return await ApplyMutation(playlistId, p => + { + if (bool.TryParse(value, out var v)) p.SupportsLevelVoting = v; + }); + } + + [HttpPost("/playlists/{playlistId:long}/restrictions")] + [HttpPut("/playlists/{playlistId:long}/restrictions")] + [Authorize] + public async Task PlaylistRestrictions(long playlistId) + { + var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors"); + var screens = await ReadFieldAsync("supportsScreens", "SupportsScreens"); + var teleport = await ReadFieldAsync("supportsTeleportVR", "SupportsTeleportVR"); + var walk = await ReadFieldAsync("supportsWalkVR", "SupportsWalkVR"); + return await ApplyMutation(playlistId, p => + { + if (bool.TryParse(juniors, out var j)) p.SupportsJuniors = j; + if (bool.TryParse(screens, out var c)) p.SupportsScreens = c; + if (bool.TryParse(teleport, out var t)) p.SupportsTeleportVR = t; + if (bool.TryParse(walk, out var w)) p.SupportsWalkVR = w; + }); + } + + [HttpPost("/playlists/{playlistId:long}/warning")] + [HttpPut("/playlists/{playlistId:long}/warning")] + [Authorize] + public async Task PlaylistWarning(long playlistId) + { + var mask = await ReadFieldAsync("warningMask", "WarningMask"); + var custom = await ReadFieldAsync("customWarning", "CustomWarning"); + return await ApplyMutation(playlistId, p => + { + if (int.TryParse(mask, out var m)) p.WarningMask = m; + if (custom is not null) p.CustomWarning = custom.Length > 512 ? custom[..512] : custom; + }); + } + + /// First value present under any of the given names, looking at + /// the form, the query string, then a JSON body. Null means "not sent", + /// which every caller treats as "leave this field alone". + private async Task ReadFieldAsync(params string[] names) + { + var all = await ReadFieldsAsync(names); + return all.Count > 0 ? all[0] : null; + } + + /// Every value present under any of the given names — the client + /// repeats a field name to send a list. + private async Task> ReadFieldsAsync(params string[] names) + { + var found = new List(); + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var n in names) + foreach (var v in form[n]) + if (!string.IsNullOrWhiteSpace(v)) found.Add(v!); + } + foreach (var n in names) + foreach (var v in Request.Query[n]) + if (!string.IsNullOrWhiteSpace(v)) found.Add(v!); + if (found.Count > 0) return found; + + if (!Request.HasFormContentType) + { + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body); + Request.Body.Position = 0; + var root = doc.RootElement; + + // The tags endpoint sends a bare JSON array of strings. + if (root.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var el in root.EnumerateArray()) + if (el.ValueKind == System.Text.Json.JsonValueKind.String) + found.Add(el.GetString()!); + return found; + } + + if (root.ValueKind == System.Text.Json.JsonValueKind.Object) + { + foreach (var n in names) + { + if (!root.TryGetProperty(n, out var v)) continue; + if (v.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var el in v.EnumerateArray()) found.Add(el.ToString()); + } + else if (v.ValueKind != System.Text.Json.JsonValueKind.Null) + { + found.Add(v.ToString()); + } + if (found.Count > 0) break; + } + } + } + catch (System.Text.Json.JsonException) { /* no usable body */ } + } + return found; + } + + /// KMKPEOGJDFK union entry with the per-playlist settings this + /// controller persists spliced back in. + /// still emits + /// hardcoded defaults for exactly the fields the client mutates here + /// (Accessibility=1, WarningMask=0, + /// SupportsLevelVoting=false, Supports*=true), so a creator + /// who published or restricted a playlist got the default echoed straight + /// back and the watch's toggle snapped to its old position. Key names are + /// the client's own setter fields — + /// NLDBPDCNNCF_NestedType_{BHAHEJIEIMF,PJFBAKCHIHI,GNCDFBNCADM,AIDDFKACPLN}.txt + /// — matched to the PascalCase read keys at + /// MKAMHOIHOJK.txt:516-612. + private static IDictionary UnionEntry(PlaylistEntity p) + { + var entry = RoomsController.BuildPlaylistUnionEntry(p); + entry["Accessibility"] = p.Accessibility; + entry["WarningMask"] = p.WarningMask; + entry["CustomWarning"] = p.CustomWarning; + entry["SupportsLevelVoting"] = p.SupportsLevelVoting; + entry["SupportsJuniors"] = p.SupportsJuniors; + entry["SupportsScreens"] = p.SupportsScreens; + entry["SupportsTeleportVR"] = p.SupportsTeleportVR; + entry["SupportsWalkVR"] = p.SupportsWalkVR; + return entry; + } private async Task ApplyMutation(long playlistId, Action mutator) { @@ -205,7 +360,7 @@ public class PlaylistsController( public async Task CreatedByMe() { var rows = await playlists.CreatedByAsync(Me); - return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList()); + return Ok(rows.Select(UnionEntry).ToList()); } [HttpGet("/playlists/cheeredby/me")] @@ -213,7 +368,7 @@ public class PlaylistsController( public async Task CheeredByMe() { var rows = await playlists.CheeredByAsync(Me); - return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList()); + return Ok(rows.Select(UnionEntry).ToList()); } [HttpGet("/playlists/favoritedby/me")] @@ -221,36 +376,122 @@ public class PlaylistsController( public async Task FavoritedByMe() { var rows = await playlists.FavoritedByAsync(Me); - return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList()); + return Ok(rows.Select(UnionEntry).ToList()); } - /// GET /playlists/visitedby/me — we don't track - /// per-player playlist visits yet (visits are tracked at the Room - /// level via RoomVisitEntity). Return empty so the watch's - /// playlists tab renders cleanly; populated once we add a - /// PlaylistVisitEntity or derive from member-room visits. + /// GET /playlists/visitedby/me — bare + /// List<CNIPGJIIFJF> (return type at + /// NLDBPDCNNCF.txt:4312, URL at :4355). + /// + /// We have no PlaylistVisitEntity, but a playlist is nothing but an + /// ordered set of rooms, so "visited" is derivable exactly: the player + /// has visited a playlist iff they've visited any of its member rooms. + /// Ordered by the most recent member-room visit so the tab reads as a + /// history list. Previously hardcoded to an empty array, which left the + /// watch's recently-visited playlists tab permanently blank. + /// + /// Aggregation happens in memory rather than in SQL: a single player's + /// RoomVisits row set is small, and this keeps the query provider-neutral + /// (SQLite in dev, Postgres in prod). [HttpGet("/playlists/visitedby/me")] [Authorize] - public IActionResult VisitedByMe() => Ok(Array.Empty()); + public async Task VisitedByMe() + { + var me = Me; + + var visits = await db.RoomVisits + .Where(v => v.PlayerId == me) + .Select(v => new { v.RoomId, v.LastVisitAt }) + .ToListAsync(); + if (visits.Count == 0) return Ok(Array.Empty()); + + var visitedRoomIds = visits.Select(v => v.RoomId).Distinct().ToList(); + var links = await db.PlaylistRooms + .Where(l => visitedRoomIds.Contains(l.RoomId)) + .Select(l => new { l.PlaylistId, l.RoomId }) + .ToListAsync(); + if (links.Count == 0) return Ok(Array.Empty()); + + var lastVisitByRoom = visits + .GroupBy(v => v.RoomId) + .ToDictionary(g => g.Key, g => g.Max(v => v.LastVisitAt)); + + var orderedIds = links + .GroupBy(l => l.PlaylistId) + .Select(g => new { PlaylistId = g.Key, LastVisitAt = g.Max(l => lastVisitByRoom[l.RoomId]) }) + .OrderByDescending(x => x.LastVisitAt) + .Select(x => x.PlaylistId) + .ToList(); + + // BulkAsync preserves caller order, so the recency sort survives. + var rows = await playlists.BulkAsync(orderedIds); + return Ok(rows.Select(UnionEntry).ToList()); + } // ── Bulk lookup ────────────────────────────────────────────────── - /// GET /playlists/bulk?id=A&id=B — bulk-by-id - /// lookup for the watch's playlist cache warmup. Bare list of - /// KMKPEOGJDFK entries, one per resolved id (missing ids - /// silently dropped). + /// + /// playlists/bulk — bulk hydration for the watch's playlist cache. + /// The client has TWO overloads on this one URL, and both pick their verb + /// at runtime: + /// + /// by id — NLDBPDCNNCF.txt:3408-3597; repeated field + /// id (NLDBPDCNNCF_NestedType_IOAECIJDKDD.txt:64); + /// Move rsi, 2 then cmovl esi,ecx against 100 at + /// :3579-3581 — GET below 100 ids, POST at 100+. + /// by name — NLDBPDCNNCF.txt:3624-3813; repeated field + /// name (NLDBPDCNNCF_NestedType_MOBALIODDDF.txt:64); same + /// cmov against 64 at :3794-3797 — GET below 64 names, POST at + /// 64+. + /// + /// GET-only meant 405 on every large cache warmup, and query-id-only meant + /// the by-name overload silently resolved nothing. Both verbs and both key + /// sets are accepted here; the response is a bare + /// List<CNIPGJIIFJF> either way (return type at + /// NLDBPDCNNCF.txt:3408), missing entries silently dropped. + /// [HttpGet("/playlists/bulk")] + [HttpPost("/playlists/bulk")] public async Task Bulk() { - var ids = Request.Query["id"].Concat(Request.Query["ids"]) - .SelectMany(v => (v ?? string.Empty).Split(',', + var ids = (await ReadFieldsAsync("id", "ids", "Id", "Ids")) + .SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Select(s => long.TryParse(s, out var n) ? n : 0) .Where(n => n != 0) .ToList(); - if (ids.Count == 0) return Ok(Array.Empty()); - var rows = await playlists.BulkAsync(ids); - return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList()); + + // Names are NOT comma-split — the client repeats the field once per + // name, and playlist names may legally contain commas. + var names = (await ReadFieldsAsync("name", "names", "Name", "Names")) + .Select(v => v.Trim()) + .Where(v => v.Length > 0) + .ToList(); + + if (ids.Count == 0 && names.Count == 0) return Ok(Array.Empty()); + + var results = new List(); + var seen = new HashSet(); + + if (ids.Count > 0) + foreach (var p in await playlists.BulkAsync(ids)) + if (seen.Add(p.Id)) results.Add(p); + + if (names.Count > 0) + { + var lowered = names.Select(s => s.ToLowerInvariant()).Distinct().ToList(); + var rows = await db.Playlists + .Where(e => lowered.Contains(e.Name.ToLower())) + .ToListAsync(); + + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var r in rows) byName.TryAdd(r.Name, r); + + foreach (var wanted in names) + if (byName.TryGetValue(wanted, out var hit) && seen.Add(hit.Id)) results.Add(hit); + } + + return Ok(results.Select(UnionEntry).ToList()); } // ── Detail-response builder (BMFAGMFKODA shape) ───────────────── @@ -296,7 +537,7 @@ public class PlaylistsController( .Select(t => (object)new { Type = 0, Tag = t }) .ToList(); - var result = RoomsController.BuildPlaylistUnionEntry(p); + var result = UnionEntry(p); result["Rooms"] = roomsWire; result["Tags"] = tagsWire; return Ok(result); diff --git a/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs b/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs index 8ad0c62..eacce1d 100644 --- a/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs +++ b/DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs @@ -1,15 +1,24 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using DorkNet.Server.Auth; using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.ProgressionEvents; [ApiController] -public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsService serverSettings) : ControllerBase +public class ProgressionEventsController( + DorkNetDbContext db, + ServerSettingsService serverSettings, + IConfiguration config) : ControllerBase { + /// CurrencyType enum value the rest of the server uses for + /// RecCenter tokens (mirrors ProgressionController.RecCenterTokens). + private const int RecCenterTokens = 2; + [HttpGet("api/progressionEvents")] [Authorize] public async Task Progress() @@ -39,30 +48,29 @@ public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsServ var weekly = await serverSettings.GetWeeklyChallengesAsync(); var (start, end) = CurrentWeekWindow(); + // The boost fields must agree with api/progressionEvents/{id}/xpboosts: + // ProgressionEventsPurchasableXpBoostModel.Set() takes the guid the + // event advertises here and looks it up in the xpboosts list, and + // ProgressionEventsModel.GetPurchasableXpBoostId() gates the whole + // boost sheet on PurchasableXpBoostId being non-null. Deriving both + // from one catalogue keeps them from drifting. + var boosts = BoostCatalogue(activeId); + var headline = boosts.Count > 0 ? boosts[0] : null; return Ok(new { ProgressionEventId = activeId, Name = "Weekly Progression", - Rewards = new[] - { - new - { - ProgressionEventRewardId = activeId * 1000L, - GiftDropId = weekly.Reward.GiftDropId != 0 ? weekly.Reward.GiftDropId : activeId, - ImageName = string.Empty, - Xp = weekly.Reward.Xp, - RewardIndex = 0, - IsBonus = false, - }, - }, + Rewards = BuildRewards(weekly.Reward, activeId), KeepsakeRoomLists = Array.Empty(), StartTime = start, EndTime = end, CollectionEndTime = end, - UsesBoost = false, + UsesBoost = headline is not null, BoostDailyGameplayMinutesLimit = 0, - BoostXpMultiplier = 1.0, - PurchasableXpBoostId = (Guid?)null, + // ProgressionEventDTO.BoostXpMultiplier is a float at +0x50 + // (dump.cs:1198957) — emit a real number, not an integer token. + BoostXpMultiplier = (double)(headline?.XpMultiplier ?? 1), + PurchasableXpBoostId = headline?.Id, ActiveExperiment = string.Empty, ChallengesIconImageName = string.Empty, RewardsPipImageName = string.Empty, @@ -88,24 +96,349 @@ public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsServ { var me = this.RequireCurrentPlayerId(); - // Count this player's completed progression-event objectives so the - // record reflects real progress rather than a flat zero. - var completed = await db.ObjectiveProgress + // Count the reward-claim sentinels this player owns FOR THIS EVENT + // (written by Collect below). The previous query counted every + // `progressionEvent:` row, which also matches the xpBoost purchase + // sentinels StorefrontsBuyController writes — a boost purchase + // silently advanced the claimed-reward pip. + var claimPrefix = RewardClaimPrefix(progressionEventId); + var collected = await db.ObjectiveProgress .CountAsync(o => o.PlayerId == me && o.IsCompleted - && o.Key.StartsWith("progressionEvent:")); + && o.Key.StartsWith(claimPrefix)); return Ok(new { ProgressionEventId = progressionEventId, Xp = 0, - ClaimedRewardIndex = completed > 0 ? completed - 1 : -1, + ClaimedRewardIndex = collected > 0 ? collected - 1 : -1, PurchasedXpBoostCount = 0, DailyBoostGameplayMinutes = 0, XpBoostExpiresAt = (DateTime?)null, }); } + // ── Reward collection ──────────────────────────────────────────────── + + /// + /// POST api/progressionEvents/collect/{eventId}/{rewardIndex} — + /// claim one reward chest off the progression track. + /// + /// Binary evidence: RecNet.Runtime/CDBFONFHJDO.txt:1208 + /// (GKANPKEGBGE(Int64 eventId, Int32 rewardIndex)) formats + /// "{0}/collect/{1}/{2}" against "api/progressionEvents" + /// (ISIL 041/043) and moves 2 = HTTPMethods.Post into the + /// verb argument at ISIL 055. No request body is passed — the two route + /// segments are the whole request. + /// + /// The return type is FGLDKEJLAKB<FFFIMAGLKEG/FHMABOHAEED>, + /// and FHMABOHAEED is the client's GiftPackage DTO — the very + /// same object GiftManager.RunConsumeGift / + /// GiftManager.DequeueGift take + /// (Assembly-CSharp/GiftManager.txt:3226,3395), whose wire keys are + /// Id / FromPlayerId / ConsumableItemDesc / AvatarItemType / + /// AvatarItemDesc / EquipmentPrefabName / EquipmentModificationGuid / + /// CurrencyType / Currency / Xp / GiftContext / GiftRarity / Message / + /// Platform / PlatformsToSpawnOn / BalanceType + /// (RecNet.Runtime/FKANCKLCCDI.txt:1051-1390). + /// + /// That means the client will POST the returned Id back to + /// api/avatar/v2/gifts/consume, so this MUST persist a real + /// rather than synthesise a wire blob — + /// an unpersisted Id 404s on consume and the player loses the reward. + /// The payload itself comes from the admin-configured weekly reward + /// (), i.e. exactly what + /// advertises on the track, so the chest hands over + /// what the UI promised. XP / tokens / level ride on the package and are + /// granted by the existing consume path, not here — granting in both + /// places would double-pay. + /// + /// Idempotent: the claim sentinel is an + /// whose Key embeds the created gift id, so a retried or duplicated POST + /// returns the original package instead of minting a second one. + /// No GiftPackageReceived push is sent (unlike + /// avatar/v2/gifts/generate): the RRUI reward flow already spawns + /// the box from this response, and a push would spawn a second one. + /// + [HttpPost("api/progressionEvents/collect/{eventId:long}/{rewardIndex:int}")] + [Authorize] + public async Task Collect(long eventId, int rewardIndex) + { + var me = this.RequireCurrentPlayerId(); + + // Only the live event's track is claimable — the client only ever + // renders the active event, and letting an arbitrary id through + // would let a replayed request re-roll last week's chest. + var activeId = CurrentProgressionEventId(); + if (eventId != activeId) return NotFound(); + + var weekly = await serverSettings.GetWeeklyChallengesAsync(); + var rewards = BuildRewards(weekly.Reward, activeId); + if (rewardIndex < 0 || rewardIndex >= rewards.Count) return NotFound(); + + // Already claimed? The sentinel key carries the gift id so we can + // hand back the same package (the client may retry after a dropped + // response, and it needs a consumable Id either way). + var sentinelPrefix = $"{RewardClaimPrefix(eventId)}{rewardIndex}:gift:"; + var sentinel = await db.ObjectiveProgress + .FirstOrDefaultAsync(o => o.PlayerId == me && o.Key.StartsWith(sentinelPrefix)); + if (sentinel is not null) + { + if (long.TryParse(sentinel.Key[sentinelPrefix.Length..], out var priorId)) + { + var prior = await db.GiftPackages + .FirstOrDefaultAsync(g => g.Id == priorId && g.RecipientPlayerId == me); + if (prior is not null) + return Ok(global::DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(prior)); + } + // Sentinel exists but its package was purged — drop the stale + // marker and fall through to re-issue, otherwise the player is + // permanently locked out of a reward they never received. + db.ObjectiveProgress.Remove(sentinel); + await db.SaveChangesAsync(); + } + + var reward = weekly.Reward; + var gift = new GiftPackageEntity + { + RecipientPlayerId = me, + FromPlayerId = null, + // The weekly reward config carries a raw avatar desc, which is + // always an outfit item (AvatarItemType 0); hair dyes are + // configured through ConsumableItemDesc instead. Leave the type + // null when there is no avatar payload so the client doesn't + // render an empty outfit tile. + AvatarItemType = string.IsNullOrWhiteSpace(reward.AvatarItemDesc) ? (int?)null : 0, + AvatarItemDescOrHairDyeDesc = reward.AvatarItemDesc, + ConsumableItemDesc = reward.ConsumableItemDesc, + EquipmentPrefabName = reward.EquipmentPrefabName, + EquipmentModificationGuid = reward.EquipmentModificationGuid, + CurrencyType = reward.Tokens > 0 ? RecCenterTokens : 0, + Currency = reward.Tokens, + Xp = reward.Xp, + Level = reward.Level, + GiftContext = reward.GiftContext, + GiftRarity = reward.GiftRarity, + Message = $"Progression event reward {rewardIndex + 1}", + Platform = -1, + PackageVariant = "Standard", + PackageMaterial = string.Empty, + IsValid = true, + SupportsCurrentPlatform = true, + }; + db.GiftPackages.Add(gift); + await db.SaveChangesAsync(); + + db.ObjectiveProgress.Add(new ObjectiveProgressEntity + { + PlayerId = me, + Key = $"{sentinelPrefix}{gift.Id}", + IsCompleted = true, + ClearedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + + return Ok(global::DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift)); + } + + // ── Purchasable XP boosts ──────────────────────────────────────────── + + /// + /// GET api/progressionEvents/{eventId}/xpboosts — the boost + /// offers the client's purchase sheet renders. + /// + /// Binary evidence: RecNet.Runtime/CDBFONFHJDO.txt:1465 + /// (HKMBEFABIBD(Int64 eventId)) formats + /// "{0}/{1}/xpboosts" against "api/progressionEvents" + /// (ISIL 026/028) and moves 0 = HTTPMethods.Get into the + /// verb argument at ISIL 034. The declared return type is + /// List<ProgressionEventPurchasableXpBoostDTO>, i.e. a bare + /// JSON array — no paged container. + /// + /// Element keys and their order come from the generated Utf8Json + /// formatter RecNet.Runtime/OFGIPBHIAGF.txt:605-698: + /// ProgressionEventPurchasableXpBoostId (Guid), Cost (Int32), + /// XpMultiplier (Int32 — get_XpMultiplierFromBoost reads the DTO's + /// +0x24 int field, NOT a float), XpCap (Int32), LookbackDurationTicks + /// (Int64), CooldownDurationTicks (Int64). + /// + /// The boost id must be STABLE across calls: the client caches it from + /// here, passes it to previewEarnedXp, and posts it as + /// purchasableXpBoostId to + /// api/storefronts/v1/buyProgressionEventXpBoost (which keys its + /// purchase sentinel on it). See . + /// + [HttpGet("api/progressionEvents/{eventId:long}/xpboosts")] + [AllowAnonymous] + public IActionResult XpBoosts(long eventId) => + Ok(BoostCatalogue(eventId).Select(b => new + { + ProgressionEventPurchasableXpBoostId = b.Id, + b.Cost, + b.XpMultiplier, + b.XpCap, + b.LookbackDurationTicks, + b.CooldownDurationTicks, + })); + + /// + /// GET api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp + /// — how much XP buying this boost right now would hand the caller. + /// + /// Binary evidence: RecNet.Runtime/CDBFONFHJDO.txt:1612 + /// (GBNFGMOGLNG(Int64 eventId, Guid boostId)) formats + /// "{0}/{1}/xpboosts/{2}/previewEarnedXp" against + /// "api/progressionEvents" (ISIL 036/038) and moves 0 = + /// HTTPMethods.Get into the verb argument at ISIL 049. The return + /// type is FGLDKEJLAKB<System.Int32> — a BARE integer body, + /// not an object; the value lands in + /// ProgressionEventsPurchasableXpBoostModel.get_XpEarnedFromBoost + /// and is echoed back as the expectedXp form field of + /// buyProgressionEventXpBoost, which grants exactly that much. + /// + /// Cooldown is real state: buyProgressionEventXpBoost stamps + /// progressionEvent:{eventId}:xpBoost:{guid:N} in ObjectiveProgress + /// on every purchase, so a boost still inside its + /// CooldownDurationTicks window previews as 0 and the client's + /// get_XPBoostIsAvailableForPurchase keeps the button disabled. + /// + /// Fidelity note: live Rec Room multiplied the XP the player actually + /// earned during LookbackDurationTicks. DorkNet has no timestamped + /// XP ledger ( mutates + /// PlayerEntity.XP in place), so a boost here pays its configured + /// XpCap — the cap is the payout, and XpMultiplier is the + /// figure the sheet renders as "Nx". + /// + [HttpGet("api/progressionEvents/{eventId:long}/xpboosts/{boostId:guid}/previewEarnedXp")] + [Authorize] + public async Task PreviewEarnedXp(long eventId, Guid boostId) + { + var me = this.RequireCurrentPlayerId(); + + var offer = BoostCatalogue(eventId).FirstOrDefault(b => b.Id == boostId); + if (offer is null) return Ok(0); + + // Same key StorefrontsBuyController.BuyProgressionEventXpBoost writes. + var purchaseKey = $"progressionEvent:{eventId}:xpBoost:{boostId:N}"; + var lastPurchase = await db.ObjectiveProgress + .Where(o => o.PlayerId == me && o.Key == purchaseKey) + .Select(o => o.ClearedAt) + .FirstOrDefaultAsync(); + + if (offer.CooldownDurationTicks > 0 + && lastPurchase is DateTime at + && DateTime.UtcNow - at < TimeSpan.FromTicks(offer.CooldownDurationTicks)) + { + return Ok(0); + } + + return Ok(offer.XpCap); + } + + // ── Shared shape helpers ───────────────────────────────────────────── + + /// One pip on the progression track. Field types are taken from + /// ProgressionEventRewardDTO (dump.cs:1199338): ProgressionEventRewardId + /// is Int64 at +0x10 but GiftDropId is Int32 at +0x18 (the audit doc + /// says Int64 — the binary disagrees), then string ImageName, Int32 Xp, + /// Int32 RewardIndex, bool IsBonus. + public sealed record EventReward( + long ProgressionEventRewardId, + int GiftDropId, + string ImageName, + int Xp, + int RewardIndex, + bool IsBonus); + + /// The progression track's reward list. One source of truth for + /// (which renders the pips) and + /// (which bounds-checks the claimed index), so the two can't drift into a + /// state where the client can see a chest the server refuses to open. + private static IReadOnlyList BuildRewards(WeeklyChallengeReward reward, long activeId) => + [ + new EventReward( + ProgressionEventRewardId: activeId * 1000L, + // yyyyMMdd fits Int32 comfortably, so the event id doubles as a + // stable placeholder drop id when no reward drop is configured. + GiftDropId: reward.GiftDropId != 0 ? reward.GiftDropId : (int)activeId, + ImageName: string.Empty, + Xp: reward.Xp, + RewardIndex: 0, + IsBonus: false), + ]; + + private static string RewardClaimPrefix(long eventId) => $"progressionEvent:{eventId}:reward:"; + + // ── XP-boost catalogue ─────────────────────────────────────────────── + + public sealed record XpBoostOffer( + Guid Id, + int Cost, + int XpMultiplier, + int XpCap, + long LookbackDurationTicks, + long CooldownDurationTicks); + + /// The boosts this deployment sells for a given event. + /// + /// Configured under ProgressionEvents:XpBoosts as an array of + /// {Cost, XpMultiplier, XpCap, LookbackMinutes, CooldownMinutes}; + /// with nothing configured we fall back to a single built-in offer (same + /// pattern as + /// and the built-in NUX checklist). Set + /// ProgressionEvents:EnableXpBoosts to false to sell none — the + /// event then reports UsesBoost=false and the sheet stays shut. + private IReadOnlyList BoostCatalogue(long eventId) + { + if (!config.GetValue("ProgressionEvents:EnableXpBoosts", true)) + return []; + + var configured = config.GetSection("ProgressionEvents:XpBoosts").GetChildren().ToList(); + if (configured.Count == 0) + { + return + [ + new XpBoostOffer( + Id: OfferId(eventId, 0), + Cost: 250, + XpMultiplier: 2, + XpCap: 500, + LookbackDurationTicks: TimeSpan.FromHours(1).Ticks, + CooldownDurationTicks: TimeSpan.FromHours(24).Ticks), + ]; + } + + var offers = new List(configured.Count); + for (var i = 0; i < configured.Count; i++) + { + var section = configured[i]; + var cap = Math.Max(0, section.GetValue("XpCap", 500)); + offers.Add(new XpBoostOffer( + Id: OfferId(eventId, i), + Cost: Math.Max(0, section.GetValue("Cost", 250)), + XpMultiplier: Math.Max(1, section.GetValue("XpMultiplier", 2)), + XpCap: cap, + LookbackDurationTicks: TimeSpan.FromMinutes( + Math.Max(0, section.GetValue("LookbackMinutes", 60))).Ticks, + CooldownDurationTicks: TimeSpan.FromMinutes( + Math.Max(0, section.GetValue("CooldownMinutes", 1440))).Ticks)); + } + return offers; + } + + /// Deterministic per-(event, slot) boost id. The client caches + /// the guid from and replays it to + /// and to + /// storefronts/v1/buyProgressionEventXpBoost, whose purchase / + /// cooldown sentinel is keyed on it — a freshly generated guid per request + /// would make every preview miss its own catalogue entry and reset the + /// cooldown on every call. Derived rather than stored because the boost + /// catalogue itself lives in configuration, not the database. + private static Guid OfferId(long eventId, int index) => + new(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes($"dorknet:progressionEventXpBoost:{eventId}:{index}")) + .AsSpan(0, 16)); + private static long CurrentProgressionEventId() { var (start, _) = CurrentWeekWindow(); diff --git a/DorkNet.Server/Controllers/API/Role/RoleController.cs b/DorkNet.Server/Controllers/API/Role/RoleController.cs index 836651d..5c7bed1 100644 --- a/DorkNet.Server/Controllers/API/Role/RoleController.cs +++ b/DorkNet.Server/Controllers/API/Role/RoleController.cs @@ -72,6 +72,30 @@ public class RoleController(DorkNetDbContext db) : ControllerBase return Ok(isAdmin ?? false); } + /// The March-2023 client probes this one in addition to + /// role/developer. Both calls funnel through the same helper + /// (OPMAPIOEIFG.ILHILCFCNFF, which formats "role/{0}/{1}" + /// and issues it with HTTPMethods.GET); the two call sites differ only + /// in the role literal they pass — "developer" at + /// RecNet.Runtime/OPMAPIOEIFG.txt:9402 and "moderator" at + /// :9471. The 2020 build never referenced the moderator probe, so it + /// used to 404 for every remote player on the 2023 client. + /// + /// DorkNet has no dedicated moderator bit, so admins and community-team + /// members answer true — they are the accounts that actually hold + /// moderation powers here. + [HttpGet("/role/moderator/{accountId:long}")] + [HttpGet("/api/role/moderator/{accountId:long}")] + [AllowAnonymous] + public async Task> IsModerator(long accountId) + { + var isMod = await db.Players + .Where(p => p.Id == accountId) + .Select(p => (bool?)(p.IsAdmin || p.IsCommunityTeam)) + .FirstOrDefaultAsync(); + return Ok(isMod ?? false); + } + /// Future-proofing for a hypothetical /// role/communityteam/{id} probe — not referenced by the 2020 /// build, but mirrors the developer shape so a patched watch can diff --git a/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs b/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs index 675d8c8..605fad9 100644 --- a/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs +++ b/DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs @@ -18,10 +18,16 @@ namespace DorkNet.Server.Controllers.API.RoomConsumables; /// LPFAFIGAIOE / HJOKFJKKEJM / IDBFKCFEAHM / PECGEJAAMHB / DPNLANKOHON): /// /// desc = { RoomConsumableId, RoomId, Name, Description, ImageName, -/// PriceAndCurrency: { Price, CurrencyId } } +/// Price, PurchaseCurrencyId, ModifiedAt } /// inventory = { RoomConsumableId, AccountId, Count, ConcurrencyCode, /// ModifiedAt, Consumable: desc } /// +/// The desc price is FLAT on the way OUT even though it is NESTED on the way +/// IN. The response formatter FCIBLPCOODP's key map is exactly the eight keys +/// above — Price at index 5, PurchaseCurrencyId at 6, ModifiedAt at 7, and no +/// PriceAndCurrency key at all (FCIBLPCOODP.txt:670/686/710). See ToDescWire. +/// The inventory key map is likewise exact (CICEHLNDLDE.txt:447-562). +/// /// Consumable must never be null on an inventory row — the /// client's RoomConsumablesManager dereferences it unconditionally while /// processing the room-join inventory fetch and NREs otherwise (that NRE @@ -31,8 +37,13 @@ namespace DorkNet.Server.Controllers.API.RoomConsumables; /// Purchase requests carry the client-generated inventory concurrency /// token: { ConcurrencyCodes: { CurrentConcurrencyCode, /// NewConcurrencyCode }, ExpectedPriceAndCurrency: { Price, CurrencyId } }. -/// The purchase response has no inventory item — the client applies -/// NewConcurrencyCode locally, so the server must store it verbatim. +/// The server must store NewConcurrencyCode verbatim, because the client +/// adopts the code it sent regardless of what comes back — anything else +/// makes the next consume fail with ConcurrencyCodeMismatch. +/// +/// Both purchase responses derive from the same base DTO (OKHNOPLBOFP), +/// whose sole field OperationResult is the object +/// { Status, InventoryItem } — see TokenResponse below. /// [ApiController] public class RoomConsumablesController(DorkNetDbContext db, LevelService level) : ControllerBase @@ -54,7 +65,12 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) public const int DescriptionTooLong = 27; public const int ConcurrencyCodeMismatch = 32; public const int PlayerDoesNotOwnConsumable = 33; + public const int PurchaseFailed = 35; public const int RoomNotFound = 36; + public const int RequestedPriceDoesNotMatch = 38; + public const int RequestedCurrencyDoesNotMatch = 39; + public const int ConsumableCannotBePurchasedWithRoomCurrency = 40; + public const int ConsumableCannotBePurchasedWithTokens = 41; } /// Token-purchase OperationResult (client enum CABBDKFODEC). @@ -223,29 +239,40 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) return StatusBody(Status.Success); } - /// POST api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens + /// PUT api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens /// — buy one stack unit with tokens. Response is - /// { OperationResult, BalanceUpdateResult, TokenBalanceResponse: - /// { CurrencyType, Balance, Platform } }. + /// { OperationResult: { Status, InventoryItem }, BalanceUpdateResult, + /// TokenBalanceResponse: { CurrencyType, Balance, Platform } }. + // The 2023-03-21 client PUTs this (verb literal 3 = PUT, at the route + // construction in RecNet.Runtime/DCFKEFHJAGC.txt instrs 097-108), so a + // POST-only mapping returned 405 and token-priced consumables were + // unbuyable. Both verbs are accepted now. + [HttpPut("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/tokens")] [HttpPost("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/tokens")] [Authorize] public async Task PurchaseTokens(Guid publicId) { var me = this.RequireCurrentPlayerId(); var item = await FindAsync(publicId); - if (item is null) return NotFound(); + // Failures answer 200 + the DTO rather than a bare status code: the + // client only ever parses this route's body through HJOKFJKKEJM, so a + // 404/400 surfaces as the opaque "Failed to purchase room consumable". + if (item is null) return Ok(TokenResponse(null, Status.RoomConsumableNotFound, 0)); var body = await ReadBodyAsync(); - var (price, currencyId, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency"); + var (price, _, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency"); var newCode = ReadConcurrency(body).NewCode; var balance = await level.GetBalanceAsync(me, TokenCurrencyType); // A token purchase is only valid for a token-priced item at the // price the client showed the buyer. - if (item.CurrencyId is not null || !hasPrice || price != item.Price) - return Ok(TokenResponse(TokenOp.RequestedPriceDoesNotMatch, balance)); + if (item.CurrencyId is not null) + return Ok(TokenResponse(null, Status.ConsumableCannotBePurchasedWithTokens, balance)); + if (!hasPrice || price != item.Price) + return Ok(TokenResponse( + TokenOp.RequestedPriceDoesNotMatch, Status.RequestedPriceDoesNotMatch, balance)); if (balance < item.Price) - return Ok(TokenResponse(TokenOp.NotEnoughCredit, balance)); + return Ok(TokenResponse(TokenOp.NotEnoughCredit, Status.PurchaseFailed, balance)); var newBalance = item.Price == 0 ? balance @@ -254,56 +281,78 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) await level.GrantCurrencyAsync(item.CreatorPlayerId, TokenCurrencyType, item.Price, $"sellRoomConsumable:{item.PublicId}"); - await AddToInventoryAsync(me, item, newCode); + var own = await AddToInventoryAsync(me, item, newCode); await db.SaveChangesAsync(); - return Ok(TokenResponse(TokenOp.OK, newBalance)); + return Ok(TokenResponse(TokenOp.OK, Status.Success, newBalance, ToInventoryWire(own, item))); } - /// POST api/roomconsumables/v1/roomconsumable/{id}/purchase/currency - /// — buy with a room currency. Response is { OperationResult, - /// BalanceUpdateResult, CurrencyBalanceResponse: { AccountId, - /// CurrencyId, Balance, ModifiedAt } }. + /// PUT api/roomconsumables/v1/roomconsumable/{id}/purchase/currency + /// — buy with a room currency. Response is + /// { OperationResult: { Status, InventoryItem }, BalanceUpdateResult, + /// CurrencyBalanceResponse: { AccountId, CurrencyId, Balance, + /// ModifiedAt } }. + // PUT per the client (verb literal 3 at DCFKEFHJAGC.txt instrs 112-123); + // POST-only returned 405 and currency-priced consumables were unbuyable. + [HttpPut("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/currency")] [HttpPost("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/currency")] [Authorize] public async Task PurchaseCurrency(Guid publicId) { var me = this.RequireCurrentPlayerId(); var item = await FindAsync(publicId); - if (item is null) return NotFound(); - if (item.CurrencyId is not Guid currencyPublicId) return BadRequest("not_currency_priced"); + // As with the token route, every rejection is a 200 + DTO carrying a + // real RoomConsumableStatus; the old BadRequest("price_mismatch") + // bodies were not DTOs at all, so the client could only report the + // generic purchase failure. + if (item is null) + return Ok(CurrencyResponse(null, Status.RoomConsumableNotFound, me, Guid.Empty, 0)); + if (item.CurrencyId is not Guid currencyPublicId) + return Ok(CurrencyResponse( + null, Status.ConsumableCannotBePurchasedWithRoomCurrency, me, Guid.Empty, 0)); var currency = await db.RoomCurrencies .FirstOrDefaultAsync(c => c.PublicId == currencyPublicId && !c.IsDeleted); - if (currency is null) return BadRequest("currency_not_found"); + if (currency is null) + return Ok(CurrencyResponse(null, Status.CurrencyNotFound, me, currencyPublicId, 0)); var body = await ReadBodyAsync(); var (price, requestedCurrency, hasPrice) = ReadPriceAndCurrency(body, "ExpectedPriceAndCurrency"); var newCode = ReadConcurrency(body).NewCode; - if (!hasPrice || price != item.Price - || (requestedCurrency is Guid reqCur && reqCur != currencyPublicId)) - return BadRequest("price_mismatch"); var balanceRow = await db.RoomCurrencyBalances .FirstOrDefaultAsync(b => b.PlayerId == me && b.RoomCurrencyId == currency.Id); var balance = balanceRow?.Balance ?? 0; + + if (!hasPrice || price != item.Price) + return Ok(CurrencyResponse( + null, Status.RequestedPriceDoesNotMatch, me, currencyPublicId, balance)); + if (requestedCurrency is Guid reqCur && reqCur != currencyPublicId) + return Ok(CurrencyResponse( + null, Status.RequestedCurrencyDoesNotMatch, me, currencyPublicId, balance)); if (balance < item.Price) - return Ok(CurrencyResponse(CurrencyOp.NotEnoughCredit, me, currencyPublicId, balance)); + return Ok(CurrencyResponse( + CurrencyOp.NotEnoughCredit, Status.PurchaseFailed, me, currencyPublicId, balance)); if (item.Price > 0) { balanceRow!.Balance -= item.Price; balanceRow.UpdatedAt = DateTime.UtcNow; } - await AddToInventoryAsync(me, item, newCode); + var own = await AddToInventoryAsync(me, item, newCode); await db.SaveChangesAsync(); - return Ok(CurrencyResponse(CurrencyOp.Success, me, currencyPublicId, balanceRow?.Balance ?? 0)); + return Ok(CurrencyResponse(CurrencyOp.Success, Status.Success, me, currencyPublicId, + balanceRow?.Balance ?? 0, ToInventoryWire(own, item))); } - /// POST api/roomconsumables/v1/roomConsumable/{id}/consume + /// PUT api/roomconsumables/v1/roomConsumable/{id}/consume /// — spend one unit. Body is { CurrentConcurrencyCode, /// NewConcurrencyCode }; response { Status, InventoryItem }. /// On a code mismatch the current row is returned so the client can /// resync. + // PUT per the client (route format "{0}/v1/roomConsumable/{1}/consume" + // with verb literal 3 at RecNet.Runtime/LPNHMEFDAAG.txt:1449-1460); + // POST-only returned 405, so no consumable could ever be consumed. + [HttpPut("api/roomconsumables/v1/roomConsumable/{publicId:guid}/consume")] [HttpPost("api/roomconsumables/v1/roomConsumable/{publicId:guid}/consume")] [Authorize] public async Task Consume(Guid publicId) @@ -335,7 +384,10 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) private async Task FindAsync(Guid publicId) => await db.RoomConsumables.FirstOrDefaultAsync(c => c.PublicId == publicId && !c.IsDeleted); - private async Task AddToInventoryAsync(long playerId, RoomConsumableEntity item, Guid? newCode) + /// Grant one unit and hand back the row, so the purchase + /// responses can embed it as OperationResult.InventoryItem. + private async Task AddToInventoryAsync( + long playerId, RoomConsumableEntity item, Guid? newCode) { var own = await db.RoomConsumableOwnership .FirstOrDefaultAsync(o => o.PlayerId == playerId && o.RoomConsumableId == item.Id); @@ -345,11 +397,12 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) db.RoomConsumableOwnership.Add(own); } own.Count += 1; - // The purchase response carries no inventory row, so the client - // adopts the NewConcurrencyCode it sent — store it verbatim or - // the next consume hits ConcurrencyCodeMismatch. + // The client adopts the NewConcurrencyCode it sent regardless of what + // the response echoes back — store it verbatim or the next consume + // hits ConcurrencyCodeMismatch. if (newCode is Guid code && code != Guid.Empty) own.ConcurrencyCode = code; own.ModifiedAt = DateTime.UtcNow; + return own; } private async Task CanManageRoomAsync(RoomEntity room, long playerId) @@ -379,10 +432,32 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) Consumable = item is null ? null : ToDescWire(item), }; - private static object TokenResponse(int operationResult, long balance) => new + /// Token-purchase response (client type MJKFEHPIDME, formatter + /// HJOKFJKKEJM). + /// + /// OperationResult is an OBJECT, not a scalar. MJKFEHPIDME only + /// declares BalanceUpdateResult (+24) and TokenBalanceResponse (+32) + /// itself (MJKFEHPIDME.txt:3,23); OperationResult (+16) comes from its + /// base OKHNOPLBOFP, whose single field is typed BGHBAILNNJJ + /// (OKHNOPLBOFP.txt:3) — the same { Status, InventoryItem } pair + /// the consume route returns (BGHBAILNNJJ.txt:3,83). HJOKFJKKEJM's key + /// map puts "OperationResult" at index 2 (HJOKFJKKEJM.txt:330) and its + /// reader resolves index 2 through JECENNBIMEI<BGHBAILNNJJ> + /// (HJOKFJKKEJM.txt:1104). A bare int there made the generated reader + /// hit a number where it verifies '{', faulting the whole request with + /// "Failed to purchase room consumable" (DCFKEFHJAGC.txt:8294) — so + /// purchases were dead even once the PUT verb was accepted. + /// + /// The scalar operation enum belongs in BalanceUpdateResult + /// (Nullable<CABBDKFODEC>, index 0) — that is what the client turns + /// into the "Not enough …" / "The price for this … has changed" toast + /// (DCFKEFHJAGC.txt:12735-12760). It is null when the balance was never + /// touched. + private static object TokenResponse( + int? balanceUpdateResult, int status, long balance, object? inventoryItem = null) => new { - OperationResult = operationResult, - BalanceUpdateResult = operationResult, + OperationResult = new { Status = status, InventoryItem = inventoryItem }, + BalanceUpdateResult = balanceUpdateResult, TokenBalanceResponse = new { CurrencyType = TokenCurrencyType, @@ -391,10 +466,20 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) }, }; - private static object CurrencyResponse(int operationResult, long playerId, Guid currencyId, long balance) => new + /// Currency-purchase response (client type DEILBLCDNEA, + /// formatter IDBFKCFEAHM). Same base type as the token response, so + /// OperationResult is the same { Status, InventoryItem } + /// object; index 2 resolves through JECENNBIMEI<BGHBAILNNJJ> + /// (IDBFKCFEAHM.txt:330 key map, :1161 reader). BalanceUpdateResult is + /// Nullable<GACBLALELBP> — an enum with only Success=0 and + /// NotEnoughCredit=1, so every other failure has to be reported through + /// OperationResult.Status and leave this null. + private static object CurrencyResponse( + int? balanceUpdateResult, int status, long playerId, Guid currencyId, long balance, + object? inventoryItem = null) => new { - OperationResult = operationResult, - BalanceUpdateResult = operationResult, + OperationResult = new { Status = status, InventoryItem = inventoryItem }, + BalanceUpdateResult = balanceUpdateResult, CurrencyBalanceResponse = new { AccountId = (int)playerId, @@ -404,6 +489,18 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) }, }; + /// Consumable descriptor as the client READS it. + /// + /// The price is FLAT here — the client's response formatter FCIBLPCOODP + /// reads Price, PurchaseCurrencyId and ModifiedAt + /// directly off the descriptor (RecNet.Runtime/FCIBLPCOODP.txt:670-710). + /// Emitting a nested PriceAndCurrency object left every item in + /// every room shop reading back Price=0 with a null currency, so the whole + /// catalogue looked token-priced and free. + /// + /// Note the asymmetry: REQUEST bodies really do nest PriceAndCurrency, and + /// ReadBodyAsync below still flattens that on the way in. Only the response + /// shape changed. private static object ToDescWire(RoomConsumableEntity c) => new { RoomConsumableId = c.PublicId, @@ -411,7 +508,9 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level) c.Name, c.Description, c.ImageName, - PriceAndCurrency = new { c.Price, c.CurrencyId }, + c.Price, + PurchaseCurrencyId = c.CurrencyId, + ModifiedAt = DateTime.SpecifyKind(c.UpdatedAt, DateTimeKind.Utc), }; private static object ToInventoryWire(RoomConsumableOwnershipEntity own, RoomConsumableEntity item) => new diff --git a/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs b/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs index eb40e1b..9b8d2e4 100644 --- a/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs +++ b/DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs @@ -45,7 +45,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : Name = name, Description = Trim(ReadString(fields, "description", "Description"), 512), ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256), - DailyLimit = Math.Max(0, ReadInt(fields, "dailyLimit", "DailyLimit") ?? 0), + // "Limit" is an Int64 on the wire — the client boxes a System.Int64 + // immediately before pushing the param (MIHKAJFMIPE.txt:757-768). + // ReadInt would fail to parse anything above int.MaxValue and quietly + // store 0, so parse as long and clamp into the int column. + DailyLimit = ClampToInt(ReadLong(fields, "limit", "Limit", "dailyLimit", "DailyLimit") ?? 0), }; db.RoomCurrencies.Add(currency); await db.SaveChangesAsync(); @@ -65,7 +69,8 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : if (ReadString(fields, "name", "Name") is { } name) currency.Name = Trim(name, 64); if (ReadString(fields, "description", "Description") is { } description) currency.Description = Trim(description, 512); if (ReadString(fields, "imageName", "ImageName") is { } imageName) currency.ImageName = Trim(imageName, 256); - if (ReadInt(fields, "dailyLimit", "DailyLimit") is int dailyLimit) currency.DailyLimit = Math.Max(0, dailyLimit); + // Int64 on the wire (MIHKAJFMIPE.txt:983 block, same boxing as createCurrency). + if (ReadLong(fields, "limit", "Limit", "dailyLimit", "DailyLimit") is long dailyLimit) currency.DailyLimit = ClampToInt(dailyLimit); currency.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return Ok(ToWire(currency)); @@ -77,13 +82,20 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : var fields = await ReadFieldsAsync(); var currency = await FindCurrencyAsync(fields); if (currency is null) return NotFound(); - var playerId = ReadLong(fields, "playerId", "PlayerId") ?? Me; + // The client sends "accountId" and can ask about ANOTHER player + // (circuits read other players' balances); reading only playerId made + // every such query silently answer for the caller instead. + var playerId = ReadLong(fields, "accountId", "AccountId", "playerId", "PlayerId") ?? Me; var balance = await GetBalanceAsync(playerId, currency.Id); return Ok(new { + AccountId = (int)playerId, + CurrencyId = currency.PublicId, + Balance = balance, + ModifiedAt = DateTime.UtcNow, + // legacy aliases PlayerId = (int)playerId, RoomCurrencyId = currency.PublicId, - Balance = balance, }); } @@ -104,30 +116,106 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : .ToListAsync(); return Ok(rows.Select(x => new { + AccountId = (int)playerId, + CurrencyId = x.c.PublicId, + x.b.Balance, + ModifiedAt = DateTime.SpecifyKind(x.b.UpdatedAt, DateTimeKind.Utc), + // legacy aliases PlayerId = (int)playerId, RoomCurrencyId = x.c.PublicId, - x.b.Balance, })); } + /// Bulk currency award — the CircuitsV2 "award currency" chip. + /// + /// The client body is a BARE JSON ARRAY, not an object: the issuing method is + /// FGLDKEJLAKB<List<KBPKAAIDCIJ>> FGBCMAINCLD(List<KOEIABFLEPM>) + /// (MIHKAJFMIPE.txt:1073); it POSTs (verb 2, MIHKAJFMIPE.txt:1262) to the route + /// at MIHKAJFMIPE.txt:1260 and pushes the serialised list straight into the + /// body via BNDIAONDFFF.FJLLPHFOOJJ (MIHKAJFMIPE.txt:1284). Every + /// element carries its OWN CurrencyId/RecipientId/Amount/TransactionId + /// (request formatter GFKKOADFJKC.txt:331-398) — this is NOT "one currency, + /// one amount, many players", which is what the old handler assumed. The old + /// handler also went through , which only walks + /// an OBJECT root, so an array body produced zero fields and every award + /// 404'd. + /// + /// A per-element failure is reported in-band rather than as an HTTP error so + /// one bad row cannot void a whole batch — the response formatter + /// (JCPFCOEEELK.txt:371-454) reads exactly AccountId/CurrencyId/Success/ + /// Error/Response, and the nested Response (GAGIJHJGFJD.txt:395-486) reads + /// AccountId/CurrencyId/Balance/AmountAwarded/AwardedAt. [HttpPost("api/roomcurrencies/v1/awardCurrency/bulk")] + [HttpPut("api/roomcurrencies/v1/awardCurrency/bulk")] public async Task AwardCurrencyBulk() { - var fields = await ReadFieldsAsync(); - var currency = await FindCurrencyAsync(fields); - if (currency is null) return NotFound(); - var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId); - if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid(); + // The body stream is single-read: pull the raw text once and hand it to + // the field reader for the legacy fallback below. + var body = await ReadJsonBodyAsync(); + var awards = ParseAwards(body); - var playerIds = ReadLongList(fields, "playerIds", "PlayerIds", "accountIds", "AccountIds"); - var amount = ReadLong(fields, "amount", "Amount") ?? 0; - if (playerIds.Count == 0 || amount == 0) return BadRequest("missing_awards"); + if (awards.Count == 0) + { + // Legacy DorkNet/admin shape: one currency + playerIds + a single amount. + var fields = await ReadFieldsAsync(body); + var legacyCurrency = await FindCurrencyAsync(fields); + var legacyAmount = ReadLong(fields, "amount", "Amount") ?? 0; + if (legacyCurrency is not null && legacyAmount != 0) + foreach (var playerId in ReadLongList(fields, "playerIds", "PlayerIds", "accountIds", "AccountIds")) + awards.Add(new AwardRequest(Guid.Empty, playerId, legacyAmount, legacyCurrency.PublicId)); + } + + if (awards.Count == 0) return BadRequest("missing_awards"); var results = new List(); - foreach (var playerId in playerIds) + var currencyCache = new Dictionary(); + var manageCache = new Dictionary(); + var awardedAt = DateTime.UtcNow; + + foreach (var award in awards) { - var balance = await AddBalanceAsync(playerId, currency.Id, amount); - results.Add(new { PlayerId = (int)playerId, RoomCurrencyId = currency.PublicId, Balance = balance }); + if (!currencyCache.TryGetValue(award.CurrencyId, out var currency)) + { + currency = await db.RoomCurrencies + .FirstOrDefaultAsync(c => c.PublicId == award.CurrencyId && !c.IsDeleted); + currencyCache[award.CurrencyId] = currency; + } + + if (currency is null) + { + results.Add(AwardFailure(award, "currency_not_found")); + continue; + } + + if (!manageCache.TryGetValue(currency.RoomId, out var canManage)) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == currency.RoomId); + canManage = room is not null && await CanManageRoomAsync(room, Me); + manageCache[currency.RoomId] = canManage; + } + + if (!canManage) + { + results.Add(AwardFailure(award, "forbidden")); + continue; + } + + var balance = await AddBalanceAsync(award.RecipientId, currency.Id, award.Amount); + results.Add(new + { + AccountId = (int)award.RecipientId, + CurrencyId = currency.PublicId, + Success = true, + Error = (string?)null, + Response = new + { + AccountId = (int)award.RecipientId, + CurrencyId = currency.PublicId, + Balance = balance, + AmountAwarded = award.Amount, + AwardedAt = awardedAt, + }, + }); } await db.SaveChangesAsync(); @@ -147,8 +235,13 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : { RoomCurrencyId = currency.Id, Name = Trim(ReadString(fields, "name", "Name") ?? currency.Name, 64), - Amount = Math.Max(1, ReadInt(fields, "amount", "Amount") ?? 1), - Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0), + // Amount/Price are Int64 on the wire (MIHKAJFMIPE.txt:2351-2374 boxes + // System.Int64 for both) — parse long, clamp into the int columns. + Amount = Math.Max(1, ClampToInt(ReadLong(fields, "amount", "Amount") ?? 1)), + Price = ClampToInt(ReadLong(fields, "price", "Price") ?? 0), + // The client never sends currencyType for an offer (its params are + // CurrencyId/Name/Amount/Price/Order, MIHKAJFMIPE.txt:2336-2383); the + // default 2 = tokens is what the v2/purchase path debits. CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2), }; db.RoomCurrencyPurchaseOffers.Add(offer); @@ -168,8 +261,9 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : if (room is null || !await CanManageRoomAsync(room, Me)) return Forbid(); if (ReadString(fields, "name", "Name") is { } name) offer.Name = Trim(name, 64); - if (ReadInt(fields, "amount", "Amount") is int amount) offer.Amount = Math.Max(1, amount); - if (ReadInt(fields, "price", "Price") is int price) offer.Price = Math.Max(0, price); + // Int64 on the wire, as in createPurchaseOffer. + if (ReadLong(fields, "amount", "Amount") is long amount) offer.Amount = Math.Max(1, ClampToInt(amount)); + if (ReadLong(fields, "price", "Price") is long price) offer.Price = ClampToInt(price); if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) offer.CurrencyType = Math.Max(0, currencyType); offer.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); @@ -193,6 +287,10 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : return Ok(ToOfferWire(offer, currency)); } + /// Both verbs are mapped on purpose: the client picks GET or POST at + /// runtime from the id count (ALHIJCJOLCB.JIECAFGCODK(ids, 100), + /// MIHKAJFMIPE_NestedType_OBLJADOFIED.txt:210-212) — GET under 100 ids, POST + /// at 100+. [HttpPost("api/roomcurrencies/v1/getPurchaseOffersBatch")] [HttpGet("api/roomcurrencies/v1/getPurchaseOffersBatch")] [AllowAnonymous] @@ -200,7 +298,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : { var fields = await ReadFieldsAsync(); var offerIds = ReadGuidList(fields, "purchaseOfferIds", "PurchaseOfferIds", "offerIds", "OfferIds"); - var currencyIds = ReadGuidList(fields, "roomCurrencyIds", "RoomCurrencyIds", "currencyIds", "CurrencyIds"); + // The client calls the list param plainly "ids" + // (MIHKAJFMIPE_NestedType_OBLJADOFIED.txt:228) and it holds ROOM CURRENCY + // ids — the response is grouped per currency. Without this alias both + // filters stayed empty and the handler dumped 200 arbitrary offers. + var currencyIds = ReadGuidList(fields, "ids", "Ids", "roomCurrencyIds", "RoomCurrencyIds", "currencyIds", "CurrencyIds"); var query = db.RoomCurrencyPurchaseOffers .Where(o => !o.IsDeleted) @@ -209,8 +311,28 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : if (offerIds.Count > 0) query = query.Where(x => offerIds.Contains(x.o.PublicId)); if (currencyIds.Count > 0) query = query.Where(x => currencyIds.Contains(x.c.PublicId)); - var rows = await query.Take(200).ToListAsync(); - return Ok(rows.Select(x => ToOfferWire(x.o, x.c))); + // The client reads an "Order" key (AALDCAANEMM.txt:550) but there is no + // column to persist it into yet, so ToOfferWire reports 0 for every pack + // and the client keeps the order it received them in — order by insertion + // id so that fallback is at least stable across fetches. + var rows = await query.OrderBy(x => x.o.Id).Take(200).ToListAsync(); + + // The response is GROUPED: List<{CurrencyId, PurchaseOffers}> (formatter + // LKBGCOELLIN.txt:215-258 reads only those two keys). A flat offer list + // deserialised into rows with CurrencyId=Guid.Empty and + // PurchaseOffers=null, so the "This Room" pack list was always empty. + var groups = rows + .GroupBy(x => x.c.PublicId) + .ToDictionary(g => g.Key, g => g.Select(x => ToOfferWire(x.o, x.c)).ToList()); + + // Answer for every requested currency, including ones with no offers, so + // the client can cache a definite "no packs" instead of retrying. + var keys = currencyIds.Count > 0 ? currencyIds : groups.Keys.ToList(); + return Ok(keys.Select(id => new + { + CurrencyId = id, + PurchaseOffers = groups.TryGetValue(id, out var offers) ? offers : new List(), + })); } [HttpPost("api/roomCurrencies/v2/purchase")] @@ -222,9 +344,28 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : if (offer is null) return NotFound(); var currency = await db.RoomCurrencies.FirstAsync(c => c.Id == offer.RoomCurrencyId); var pid = Me; + + // The client states the price and amount it agreed to — the request pushes + // PurchaseOfferId (Guid), RequestedAmount (Int64) and RequestedPrice + // (Int64) in that order (DCFKEFHJAGC.txt:7810, :7822, :7834). Treat them + // as a price agreement so an offer edited mid-purchase cannot overcharge. + var requestedPrice = ReadLong(fields, "requestedPrice", "RequestedPrice"); + var requestedAmount = ReadLong(fields, "requestedAmount", "RequestedAmount"); + if ((requestedPrice is long rp && rp != offer.Price) + || (requestedAmount is long ra && ra != offer.Amount)) + return Conflict(new { Error = "offer_changed" }); + var balance = await level.GetBalanceAsync(pid, offer.CurrencyType); + + // The success DTO has room for exactly CurrencyBalanceResponse and + // TokenBalanceResponse (formatter KNGMIABFJHK.txt:215/242) — there is no + // Success flag to carry a failure — so failures must go out as non-2xx and + // take the client's own error path ("Failed to purchase currency offer", + // DCFKEFHJAGC.txt:7850). A 200 carrying {Success=false,...} deserialised + // to two null balances and looked like a successful, free purchase. if (balance < offer.Price) - return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance, offer.CurrencyType }); + return Conflict(new { Error = "insufficient_funds" }); + var newBalance = offer.Price == 0 ? balance : await level.GrantCurrencyAsync(pid, offer.CurrencyType, -offer.Price, $"roomCurrency:{offer.PublicId}"); @@ -232,11 +373,22 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : await db.SaveChangesAsync(); return Ok(new { - Success = true, - Balance = newBalance, - offer.CurrencyType, - RoomCurrencyId = currency.PublicId, - RoomCurrencyBalance = customBalance, + // Nested shapes: DPNLANKOHON.txt:331-398 {AccountId, CurrencyId, + // Balance, ModifiedAt} and PECGEJAAMHB.txt:255-298 {Balance, + // CurrencyType, Platform}. + CurrencyBalanceResponse = new + { + AccountId = (int)pid, + CurrencyId = currency.PublicId, + Balance = customBalance, + ModifiedAt = DateTime.UtcNow, + }, + TokenBalanceResponse = new + { + Balance = newBalance, + offer.CurrencyType, + Platform = 0, + }, }); } @@ -270,8 +422,14 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : private async Task AddBalanceAsync(long playerId, long currencyId, long amount) { - var row = await db.RoomCurrencyBalances - .FirstOrDefaultAsync(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId); + // Check the change tracker first: a bulk award can name the same + // (player, currency) pair twice, and an unsaved row is invisible to a + // fresh query — without this the second hit would insert a duplicate + // balance row and lose the first award. + var row = db.RoomCurrencyBalances.Local + .FirstOrDefault(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId) + ?? await db.RoomCurrencyBalances + .FirstOrDefaultAsync(b => b.PlayerId == playerId && b.RoomCurrencyId == currencyId); if (row is null) { row = new RoomCurrencyBalanceEntity { PlayerId = playerId, RoomCurrencyId = currencyId }; @@ -291,45 +449,163 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync(); } - private async Task> ReadFieldsAsync() + /// Pre-read request body. The body stream can only be + /// consumed once, so a caller that already had to look at the raw text + /// ( needs to see its ARRAY root) hands it back + /// in here instead of letting this re-read Request.Body. + private async Task> ReadFieldsAsync(string? jsonBody = null) { var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var pair in Request.Query) - fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; + fields[pair.Key] = Flatten(pair.Value); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var pair in form) - fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty; + fields[pair.Key] = Flatten(pair.Value); + return fields; } - else if ((Request.ContentLength ?? 0) > 0 - && Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + + var raw = jsonBody ?? await ReadJsonBodyAsync(); + if (string.IsNullOrWhiteSpace(raw)) return fields; + + try { - try + using var doc = JsonDocument.Parse(raw); + if (doc.RootElement.ValueKind == JsonValueKind.Object) { - using var doc = await JsonDocument.ParseAsync(Request.Body); - if (doc.RootElement.ValueKind == JsonValueKind.Object) + foreach (var prop in doc.RootElement.EnumerateObject()) { - foreach (var prop in doc.RootElement.EnumerateObject()) + fields[prop.Name] = prop.Value.ValueKind switch { - fields[prop.Name] = prop.Value.ValueKind switch - { - JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())), - JsonValueKind.String => prop.Value.GetString() ?? string.Empty, - _ => prop.Value.GetRawText(), - }; - } + JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())), + JsonValueKind.String => prop.Value.GetString() ?? string.Empty, + _ => prop.Value.GetRawText(), + }; } } - catch (JsonException) - { - } + } + catch (JsonException) + { } return fields; } + /// Collapses a multi-valued query/form key into the comma-joined form + /// / already split on. + /// + /// getPurchaseOffersBatch pushes its currency-id list through the generic batch + /// param helper (0x181F2DCD0 with "ids", + /// MIHKAJFMIPE_NestedType_OBLJADOFIED.txt:228-230), which is the same helper + /// every other RecNet batch route uses to REPEAT the key once per id + /// (?ids=a&ids=b). Taking only FirstOrDefault() silently + /// dropped every id after the first, so a multi-currency room fetched packs for + /// exactly one of its currencies. + private static string Flatten(Microsoft.Extensions.Primitives.StringValues values) => + values.Count <= 1 + ? values.FirstOrDefault() ?? string.Empty + : string.Join(",", values.Where(v => !string.IsNullOrWhiteSpace(v))); + + private async Task ReadJsonBodyAsync() + { + if ((Request.ContentLength ?? 0) <= 0) return null; + if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) != true) return null; + using var reader = new StreamReader(Request.Body, leaveOpen: true); + return await reader.ReadToEndAsync(); + } + + /// One element of the bulk-award array + /// (request formatter GFKKOADFJKC.txt:331-398: CurrencyId, RecipientId, + /// Amount, TransactionId). TransactionId is the client's idempotency token; + /// DorkNet has no transaction ledger to dedupe against, so it is parsed and + /// echoed nowhere — the response formatter does not read it back either. + private sealed record AwardRequest(Guid TransactionId, long RecipientId, long Amount, Guid CurrencyId); + + /// Parses the bare JSON array the client posts. Also tolerates an + /// {"awards":[...]} envelope for DorkNet's own tooling. + private static List ParseAwards(string? body) + { + var awards = new List(); + if (string.IsNullOrWhiteSpace(body)) return awards; + + try + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object) + { + if (JsonProp(root, "awards", "Awards") is { ValueKind: JsonValueKind.Array } envelope) + root = envelope; + else + return awards; + } + + if (root.ValueKind != JsonValueKind.Array) return awards; + foreach (var element in root.EnumerateArray()) + if (ToAward(element) is { } award) + awards.Add(award); + } + catch (JsonException) + { + } + + return awards; + } + + private static AwardRequest? ToAward(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) return null; + if (JsonGuid(element, "CurrencyId", "currencyId", "RoomCurrencyId", "roomCurrencyId") is not Guid currencyId) + return null; + if (JsonLong(element, "RecipientId", "recipientId", "AccountId", "accountId", "PlayerId", "playerId") is not long recipientId + || recipientId <= 0) + return null; + if (JsonLong(element, "Amount", "amount") is not long amount || amount == 0) return null; + return new AwardRequest( + JsonGuid(element, "TransactionId", "transactionId") ?? Guid.Empty, + recipientId, + amount, + currencyId); + } + + private static object AwardFailure(AwardRequest award, string error) => new + { + AccountId = (int)award.RecipientId, + CurrencyId = award.CurrencyId, + Success = false, + Error = error, + Response = (object?)null, + }; + + // JsonDocument property lookup is case-sensitive, so every alias is listed in + // both casings the client could plausibly emit. + private static JsonElement? JsonProp(JsonElement element, params string[] names) + { + foreach (var name in names) + if (element.TryGetProperty(name, out var value) && value.ValueKind != JsonValueKind.Null) + return value; + return null; + } + + private static Guid? JsonGuid(JsonElement element, params string[] names) => + JsonProp(element, names) is { ValueKind: JsonValueKind.String } value + && Guid.TryParse(value.GetString(), out var guid) + ? guid + : null; + + private static long? JsonLong(JsonElement element, params string[] names) => JsonProp(element, names) switch + { + { ValueKind: JsonValueKind.Number } number => number.TryGetInt64(out var value) ? value : null, + { ValueKind: JsonValueKind.String } text => long.TryParse(text.GetString(), out var value) ? value : null, + _ => null, + }; + + /// Several wire fields are Int64 while the backing columns are int; + /// clamp instead of failing the parse and silently storing 0. + private static int ClampToInt(long value) => (int)Math.Clamp(value, 0, int.MaxValue); + private static string? ReadString(Dictionary fields, params string[] names) { foreach (var name in names) @@ -373,33 +649,70 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) : return s.Length <= max ? s : s[..max]; } + /// Room currency as the client READS it (formatter PEOAOMGOMGC): + /// CurrencyId, RoomId, Name, Description, + /// CurrencyType, Limit, ImageName, CreatedAt, + /// ModifiedAt. + /// + /// The previous names were server inventions (RoomCurrencyId, + /// DailyLimit, UpdatedAt) that shared almost no keys with + /// what the client reads, so every currency came back with an empty + /// CurrencyId and a zero limit — the room currency HUD could not match a + /// balance to a currency. The legacy aliases are kept alongside the correct + /// names because unknown members are ignored client-side and other DorkNet + /// surfaces (admin UI) still read them. private static object ToWire(RoomCurrencyEntity currency) => new { + CurrencyId = currency.PublicId, + currency.RoomId, + currency.Name, + currency.Description, + CurrencyType = 300, + Limit = (long)currency.DailyLimit, + currency.ImageName, + CreatedAt = DateTime.SpecifyKind(currency.CreatedAt, DateTimeKind.Utc), + ModifiedAt = DateTime.SpecifyKind(currency.UpdatedAt, DateTimeKind.Utc), + + // legacy aliases RoomCurrencyId = currency.PublicId, Id = currency.PublicId, InternalId = currency.Id, - currency.RoomId, CreatorPlayerId = (int)currency.CreatorPlayerId, - currency.Name, - currency.Description, - currency.ImageName, currency.DailyLimit, - currency.CreatedAt, currency.UpdatedAt, }; + /// Purchase offer as the client READS it (formatter + /// AALDCAANEMM.txt:499-638): exactly CurrencyPurchaseOfferId, + /// CurrencyId, Order, Name, CurrencyAmount, + /// Price, ModifiedAt. CurrencyType/CreatedAt are + /// not read by the client and are kept only for DorkNet's admin UI, as are + /// the legacy aliases (see ). + /// + /// Order (Int32, sent by the client on createPurchaseOffer — + /// MIHKAJFMIPE.txt:2379-2383) has no column on + /// RoomCurrencyPurchaseOfferEntity yet, so it always reports 0 and the + /// pack list falls back to insertion order. Persisting it needs a schema + /// change in Data/Entities/RoomEconomyEntities.cs. private static object ToOfferWire(RoomCurrencyPurchaseOfferEntity offer, RoomCurrencyEntity currency) => new { + CurrencyPurchaseOfferId = offer.PublicId, + CurrencyId = currency.PublicId, + Order = 0, + offer.Name, + CurrencyAmount = offer.Amount, + offer.Price, + offer.CurrencyType, + CreatedAt = DateTime.SpecifyKind(offer.CreatedAt, DateTimeKind.Utc), + ModifiedAt = DateTime.SpecifyKind(offer.UpdatedAt, DateTimeKind.Utc), + + // legacy aliases PurchaseOfferId = offer.PublicId, RoomCurrencyPackageId = offer.PublicId, Id = offer.PublicId, InternalId = offer.Id, RoomCurrencyId = currency.PublicId, - offer.Name, offer.Amount, - offer.Price, - offer.CurrencyType, - offer.CreatedAt, offer.UpdatedAt, }; } diff --git a/DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs b/DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs index 8abbd18..02ab254 100644 --- a/DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs +++ b/DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs @@ -31,12 +31,21 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase return Ok(rows.Select(ToWire)); } + /// The watch key ring — keys the caller HOLDS. This used to return + /// keys the caller CREATED, which is a different set: a creator never buys + /// their own key, so players saw none of the keys they had actually + /// purchased. Keys the caller created are unioned in so room owners still + /// see their own. [HttpGet("api/roomkeys/v1/mine")] public async Task Mine() { var pid = Me; + var purchasedIds = await db.RoomKeyPurchases + .Where(p => p.PlayerId == pid) + .Select(p => p.RoomKeyId) + .ToListAsync(); var rows = await db.RoomKeys - .Where(k => k.CreatorPlayerId == pid && !k.IsDeleted) + .Where(k => !k.IsDeleted && (purchasedIds.Contains(k.Id) || k.CreatorPlayerId == pid)) .OrderByDescending(k => k.UpdatedAt) .ToListAsync(); return Ok(rows.Select(ToWire)); @@ -93,6 +102,25 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase return Ok(RoomKeyResponse(RoomKeyStatus.Success, key)); } + /// The 2023-03-21 client does not call update. It builds + /// "api/roomkeys/v1/" + action with verb PUT and a RoomKeyId + /// field, where action is one of updateAll, updateName, + /// updateDescription or updatePrice + /// (RecNet.Runtime/AHMBBJNANBP.txt: prefix concat at instr 058, verb 3 at + /// 067, "RoomKeyId" at 079; action literals at :1345, :1559, :1772, + /// :2043). None were registered, so every room-key edit 405'd. + /// + /// All four share this handler: the request DTO's fields are already + /// optional, so a partial update naturally leaves the others untouched. + [HttpPut("api/roomkeys/v1/updateAll")] + [HttpPost("api/roomkeys/v1/updateAll")] + [HttpPut("api/roomkeys/v1/updateName")] + [HttpPost("api/roomkeys/v1/updateName")] + [HttpPut("api/roomkeys/v1/updateDescription")] + [HttpPost("api/roomkeys/v1/updateDescription")] + [HttpPut("api/roomkeys/v1/updatePrice")] + [HttpPost("api/roomkeys/v1/updatePrice")] + [HttpPut("api/roomkeys/v1/update")] [HttpPost("api/roomkeys/v1/update")] public async Task Update() { @@ -140,35 +168,104 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase return Content(((int)RoomKeyStatus.Success).ToString(), "application/json"); } + /// "Has anyone bought this key?" — the creator calls this before a + /// destructive edit. It previously answered "has the CALLER bought it", + /// which is always false for the creator, so the guard never fired and + /// creators could silently invalidate keys players had paid for. [HttpGet("api/roomkeys/v1/purchased/{roomKeyId:long}")] public async Task Purchased(long roomKeyId) { - var pid = Me; - var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid); - return Content(owns ? "true" : "false", "application/json"); + var anyPurchase = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId); + return Content(anyPurchase ? "true" : "false", "application/json"); } + /// The client sends both roomKeyId and playerId — key + /// doors evaluate OTHER players' ownership, not just the caller's. Ignoring + /// playerId meant a door asked about someone else and got the caller's + /// answer. [HttpGet("api/roomkeys/v1/owns")] - public async Task Owns([FromQuery] long roomKeyId) + public async Task Owns([FromQuery] long roomKeyId, [FromQuery] long? playerId = null) { - var pid = Me; - var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid); + var subject = playerId is long p && p > 0 ? p : Me; + var owns = await db.RoomKeyPurchases.AnyAsync(x => x.RoomKeyId == roomKeyId && x.PlayerId == subject); return Content(owns ? "true" : "false", "application/json"); } + /// Bulk key-ownership probe, used by key doors to gate a whole + /// room of players at once. + /// + /// The 2023-03-21 client POSTs a JSON array of + /// {RoomKeyId, AccountId} pairs as a raw body + /// (RecNet.Runtime/AHMBBJNANBP_NestedType_JIPDBBEFAPN.txt instrs 044-065), + /// so each element names the player it is asking about. The old + /// query/form-only reader saw an empty id list and answered for the caller, + /// meaning key doors evaluated the wrong player — or nobody at all. [HttpGet("api/roomkeys/v1/owns/bulk")] [HttpPost("api/roomkeys/v1/owns/bulk")] + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] public async Task OwnsBulk() { - var ids = await ReadRoomKeyIdsAsync(); - if (ids.Count == 0) return Ok(Array.Empty()); - var pid = Me; - var owned = await db.RoomKeyPurchases - .Where(p => p.PlayerId == pid && ids.Contains(p.RoomKeyId)) - .Select(p => p.RoomKeyId) - .ToListAsync(); - var ownedSet = owned.ToHashSet(); - return Ok(ids.Select(id => new { RoomKeyId = id, Owns = ownedSet.Contains(id) }).ToList()); + var pairs = await ReadOwnershipPairsAsync(); + if (pairs.Count == 0) + { + // Legacy query/form path: ids only, always about the caller. + var ids = await ReadRoomKeyIdsAsync(); + if (ids.Count == 0) return Ok(Array.Empty()); + pairs = ids.Select(id => (RoomKeyId: id, AccountId: Me)).ToList(); + } + + var keyIds = pairs.Select(p => p.RoomKeyId).Distinct().ToList(); + var accountIds = pairs.Select(p => p.AccountId).Distinct().ToList(); + var owned = (await db.RoomKeyPurchases + .Where(p => keyIds.Contains(p.RoomKeyId) && accountIds.Contains(p.PlayerId)) + .Select(p => new { p.RoomKeyId, p.PlayerId }) + .ToListAsync()) + .Select(p => (p.RoomKeyId, p.PlayerId)) + .ToHashSet(); + + return Ok(pairs.Select(p => new + { + p.RoomKeyId, + AccountId = (int)p.AccountId, + DoesPlayerOwnRoomKey = owned.Contains((p.RoomKeyId, p.AccountId)), + // legacy alias + Owns = owned.Contains((p.RoomKeyId, p.AccountId)), + }).ToList()); + } + + /// Parse the 2023 client's JSON array of + /// {RoomKeyId, AccountId} pairs. Returns empty for a form post or a + /// body that isn't an array, so the legacy path still applies. + private async Task> ReadOwnershipPairsAsync() + { + var pairs = new List<(long, long)>(); + if (Request.HasFormContentType) return pairs; + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body); + Request.Body.Position = 0; + if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array) return pairs; + + foreach (var el in doc.RootElement.EnumerateArray()) + { + if (el.ValueKind != System.Text.Json.JsonValueKind.Object) continue; + long? Read(params string[] names) + { + foreach (var n in names) + if (el.TryGetProperty(n, out var v) && + v.ValueKind == System.Text.Json.JsonValueKind.Number && + v.TryGetInt64(out var x)) return x; + return null; + } + var keyId = Read("RoomKeyId", "roomKeyId"); + if (keyId is not long k || k <= 0) continue; + pairs.Add((k, Read("AccountId", "accountId", "PlayerId", "playerId") ?? Me)); + } + } + catch (System.Text.Json.JsonException) { /* not a JSON array body */ } + return pairs; } public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new @@ -177,6 +274,14 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase RoomKey = key is null ? null : ToWire(key), }; + /// The 2023-03-21 room-key formatter reads nine members — + /// RoomKeyId, ReplicationId, RoomId, Name, Description, Price, + /// PurchaseCurrencyId, CreatedAt, ImageName + /// (RecNet.Runtime/ANHGLIFGACE.txt:627-814 name table, :829-993 reader). + /// CreatedAt was missing and silently defaulted to DateTime.MinValue in the + /// key ring. PurchaseCurrencyId (Nullable<Guid>) and ImageName have no + /// column on RoomKeyEntity yet, so they are still omitted rather than + /// emitted as permanent nulls — both are absent-tolerant on the client. public static object ToWire(RoomKeyEntity key) => new { RoomKeyId = key.Id, @@ -187,6 +292,7 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase key.Name, key.Description, key.Price, + key.CreatedAt, }; private static RoomKeyStatus ValidateText(string? name, string? description, int price) @@ -231,50 +337,82 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase return ids.Distinct().Take(200).ToList(); } + /// Read a room-key write request. + /// + /// The 2023-03-21 client puts every scalar of create/update in the QUERY + /// STRING, not in a body: BNDIAONDFFF.AFGEDDANEKP only appends to the + /// param dictionary at field +0x20 (BNDIAONDFFF.txt:492-506), and the send + /// path joins that dictionary with "&" and glues it onto the URL + /// behind "?" (BNDIAONDFFF.txt:3196, 3238, 3248-3251). So + /// POST api/roomkeys/v1/create and + /// PUT api/roomkeys/v1/update* arrive with an EMPTY body — reading + /// form/JSON only left Name null and RoomId 0, i.e. every create answered + /// NameTooShort and every edit DoesNotExist. + /// + /// Query wins, form second, and a JSON body is only parsed when neither + /// carried anything (older clients and the admin UI still post JSON). private async Task ReadBodyAsync() where T : new() { - if (Request.HasFormContentType) + var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null; + + string? Field(params string[] names) { - var form = await Request.ReadFormAsync(); - if (typeof(T) == typeof(NewRoomKeyRequest)) + foreach (var name in names) + { + var q = Request.Query[name].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(q)) return q; + if (form is not null) + { + var f = form[name].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(f)) return f; + } + } + return null; + } + + if (typeof(T) == typeof(NewRoomKeyRequest)) + { + var roomId = Field("roomId", "RoomId"); + var name = Field("name", "Name"); + var description = Field("description", "Description"); + var price = Field("price", "Price"); + if (roomId is not null || name is not null || description is not null || price is not null) { object req = new NewRoomKeyRequest { - RoomId = long.TryParse(form["roomId"].FirstOrDefault() - ?? form["RoomId"].FirstOrDefault(), out var roomId) - ? roomId - : 0, - Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(), - Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(), - Price = int.TryParse(form["price"].FirstOrDefault() - ?? form["Price"].FirstOrDefault(), out var price) - ? price - : 0, + RoomId = long.TryParse(roomId, out var parsedRoomId) ? parsedRoomId : 0, + Name = name, + Description = description, + Price = int.TryParse(price, out var parsedPrice) ? parsedPrice : 0, }; return (T)req; } - - if (typeof(T) == typeof(UpdateRoomKeyRequest)) + } + else if (typeof(T) == typeof(UpdateRoomKeyRequest)) + { + // updateName sends only Name, updatePrice only Price, etc. — every + // field the client omitted stays null here and the handler keeps the + // stored value (AHMBBJNANBP.txt:1300-1345, 1550-1559, 1763-1772, + // 2018-2043). + var roomKeyId = Field("roomKeyId", "RoomKeyId"); + var name = Field("name", "Name"); + var description = Field("description", "Description"); + var price = Field("price", "Price"); + if (roomKeyId is not null || name is not null || description is not null || price is not null) { object req = new UpdateRoomKeyRequest { - RoomKeyId = long.TryParse(form["roomKeyId"].FirstOrDefault() - ?? form["RoomKeyId"].FirstOrDefault(), out var roomKeyId) - ? roomKeyId - : 0, - Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(), - Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(), - Price = int.TryParse(form["price"].FirstOrDefault() - ?? form["Price"].FirstOrDefault(), out var price) - ? price - : null, + RoomKeyId = long.TryParse(roomKeyId, out var parsedKeyId) ? parsedKeyId : 0, + Name = name, + Description = description, + Price = int.TryParse(price, out var parsedPrice) ? parsedPrice : null, }; return (T)req; } - - return new T(); } + if (form is not null) return new T(); + try { return await JsonSerializer.DeserializeAsync(Request.Body, JsonOptions) ?? new T(); @@ -300,6 +438,8 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase public sealed class UpdateRoomKeyRequest { + // The 2023 client spells this "RoomKeyId"; System.Text.Json is + // configured case-insensitively so both land on this property. [JsonPropertyName("roomKeyId")] public long RoomKeyId { get; set; } [JsonPropertyName("name")] public string? Name { get; set; } [JsonPropertyName("description")] public string? Description { get; set; } diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index e6ae0db..2fc0275 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -53,14 +53,48 @@ public class RoomsController( [FromQuery] string? tags) => Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList()); + /// GET rooms/hot — the room-server "hot rooms" list. + /// Response is the PAGED wrapper: the 2023 client's issuing method + /// NLDBPDCNNCF.FANOAMOPHBE returns Task<FFCPFGBNLHN> + /// (NLDBPDCNNCF.txt:2865 + literal at :2983) and FFCPFGBNLHN's reader + /// requires Results + TotalResults + /// (PJPBJKLDMNA.txt ISIL 040/059). + // The roomserver/ twin is mandatory: FANOAMOPHBE lives on the same + // NLDBPDCNNCF client whose requests all arrive with the roomserver/ + // prefix in this deployment (docs/recroom-2023-room-save.md:83-93), so + // the bare-only registration 404'd for the 2023 client. [HttpGet("rooms/hot")] + [HttpGet("roomserver/rooms/hot")] public async Task HotRoomServer( - [FromQuery] string? tag, - [FromQuery] string? tags, [FromQuery] int skip = 0, [FromQuery] int take = 100) { - var all = await rooms.HotAsync(tag ?? tags, take: 200); + // The tag filter is a REPEATED query field (`tag=a&tag=b`) — the + // client's IFLHPHIPFNC closure adds one entry per selected chip — + // so a `[FromQuery] string? tag` binding silently dropped every tag + // past the first. Read the raw collection and union the per-tag hits. + var tagFilters = Request.Query["tag"] + .Concat(Request.Query["tags"]) + .SelectMany(v => (v ?? string.Empty).Split(',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + List all; + if (tagFilters.Count == 0) + { + all = await rooms.HotAsync(null, take: 200); + } + else + { + var merged = new List(); + var seen = new HashSet(); + foreach (var filter in tagFilters) + foreach (var room in await rooms.HotAsync(filter, take: 200)) + if (seen.Add(room.Id)) merged.Add(room); + all = merged.OrderByDescending(r => r.HotScore).ToList(); + } + var page = all .Skip(Math.Max(skip, 0)) .Take(Math.Clamp(take, 1, 100)) @@ -151,16 +185,37 @@ public class RoomsController( return Ok(await BuildRoomServerListAsync(rows)); } + /// GET rooms/magic_door — the Magic Door destination. + /// The response is a WRAPPER, not the bare room: the client's + /// MOOCMPOIIGM returns Task<GAPJAGDNFEO> + /// (NLDBPDCNNCF.txt:4503) and that reader requires the keys + /// RefreshesAt, RefreshIntervalMinutes and Room + /// (MIFGPMGEMEK.txt ISIL 046/073/097). Returning the room object + /// directly left Room null and MagicDoorManager with no + /// destination. The partySize query param is accepted and + /// ignored — we have no party-size-aware curation. [HttpGet("rooms/magic_door")] [HttpGet("roomserver/rooms/magic_door")] - public async Task MagicDoor() + public async Task MagicDoor([FromQuery] int? partySize) { var room = await PublicRoomQuery() .OrderByDescending(r => r.HotScore) .ThenBy(r => r.Id) .FirstOrDefaultAsync(); if (room is null) return NotFound(); - return Ok((await BuildRoomServerListAsync(new[] { room })).First()); + + // The door re-rolls its destination on the client every + // RefreshIntervalMinutes; RefreshesAt is when the current pick + // expires. Keep the two consistent so the client's local timer + // and its next fetch line up. + const int refreshIntervalMinutes = 10; + return Ok(new + { + Room = (await BuildRoomServerListAsync(new[] { room })).First(), + RefreshesAt = DateTime.UtcNow.AddMinutes(refreshIntervalMinutes) + .ToString("yyyy-MM-ddTHH:mm:ssZ"), + RefreshIntervalMinutes = refreshIntervalMinutes, + }); } /// @@ -263,8 +318,14 @@ public class RoomsController( /// Unlike the api/rooms/v* variants (flat List<Room>), /// this one's client contract is a PAGED wrapper object /// { Results, TotalResults } (IDLBPALJJDJ : PagedResult<Room>); - /// returning a bare array crashes the watch's key-based reader. + /// returning a bare array crashes the watch's key-based reader. + /// Confirmed for 2023 too: NLDBPDCNNCF.MOBJJDBNBMF returns + /// Task<FFCPFGBNLHN> (NLDBPDCNNCF.txt:2733) whose reader + /// requires Results/TotalResults (PJPBJKLDMNA.txt ISIL 040/059). + // roomserver/ twin: the 2023 caller is on NLDBPDCNNCF, whose requests all + // carry the roomserver/ prefix (docs/recroom-2023-room-save.md:83-93). [HttpGet("rooms/search")] + [HttpGet("roomserver/rooms/search")] public async Task SearchRoomServer( [FromQuery(Name = "query")] string? query, [FromQuery(Name = "value")] string? value, @@ -304,7 +365,11 @@ public class RoomsController( /// [HttpGet("api/rooms/v2/baserooms")] [HttpGet("api/rooms/v3/baserooms")] + // The 2023 client issues this from NLDBPDCNNCF, whose requests all carry + // the roomserver/ prefix in this deployment — without the twin the base/AG + // room list 404s and the room browser's base section is empty. [HttpGet("rooms/base")] + [HttpGet("roomserver/rooms/base")] public async Task BaseRooms() { var names = await serverSettings.GetBaseRoomNamesAsync(); @@ -410,6 +475,46 @@ public class RoomsController( unityAssetVersion: unityAssetVersion)); } + /// DELETE rooms/{roomId} — the owner's "delete room" + /// button. Client method NLDBPDCNNCF.FPDBNHOHIAO formats + /// "rooms/{0}" and moves verb ordinal 4 (= DELETE) into r8 + /// (NLDBPDCNNCF.txt ISIL 021/030 around :4897); its return type is the + /// body-less promise LDGADANDBIO (CAOCJPMJMDB.txt:339), so the + /// response payload is never parsed. + /// + /// The only DELETE for rooms/{id} in the codebase lived on + /// AdminController, which is rooted at api/admin/v1 — the game + /// path therefore 405'd on the bare route and 404'd on the roomserver/ + /// one, so owners could not delete their rooms at all. + /// + /// Soft-archive (State = 1) exactly like + /// AdminController.DeleteRoom: a hard delete would orphan the + /// room's saves, visits, bookmarks and role rows. Archived rooms drop + /// out of every browse/search query (all of which filter + /// State == 0). + [HttpDelete("rooms/{roomId:long}")] + [HttpDelete("roomserver/rooms/{roomId:long}")] + [Authorize] + public async Task DeleteRoom(long roomId) + { + var me = this.RequireCurrentPlayerId(); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); + if (room is null) return NotFound(); + if (room.CreatorPlayerId != me) + { + // Co-owners (Role 0) can delete too — same gate the rename path uses. + var coOwner = await db.RoomRoles.AnyAsync(rr => + rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && rr.Role == 0); + if (!coOwner) return Forbid(); + } + + room.State = 1; // Archived + room.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + logger.LogInformation("[room-delete] room={Room} by={By}", roomId, me); + return Ok(new { Result = 0, room.Id, room.State }); + } + // ── RoomDetails (the boot-critical one) ───────────────────────────── // // /v4/details/{roomId} is hit *during* the matchmaking goto flow. @@ -1690,7 +1795,6 @@ public class RoomsController( { Type = RoomTagTypeForWire(t), Tag = t, - IsPrimaryGenre = false, }) .ToArray(); @@ -1850,6 +1954,20 @@ public class RoomsController( var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"); + // The blob the room actually loads from: sub-room 0's, override and + // all. `dataBlobName` above is the per-ROOM name, which is the wrong + // answer whenever a caller passes an override — a dorm's data is + // per-PLAYER (dorm_p{id}_v*.dat) and arrives that way. + var primarySubRoom = sceneRows is { Count: > 0 } + ? sceneRows.FirstOrDefault(s => s.OrderIndex == 0) ?? sceneRows[0] + : null; + var primarySubRoomDataBlob = + !string.IsNullOrWhiteSpace(overrideDataBlobName) && (sceneRows?.Count ?? 0) <= 1 + ? overrideDataBlobName! + : primarySubRoom is not null + ? SceneOrSyntheticDataBlobName(room, primarySubRoom.DataBlobName, dataBlobName) + : dataBlobName; + object[] subRooms = sceneRows is { Count: > 0 } ? sceneRows.Select(s => { @@ -1887,7 +2005,6 @@ public class RoomsController( RoomId = room.Id, Name = s.Name, UnityAssetId = s.StudioUnityAssetId, - SubRoomUnityAssetId = s.StudioUnityAssetId, UnityAsset = unityAsset?.Filename ?? string.Empty, UnityAssetHash = string.Empty, DataBlob = dataBlob, @@ -1898,36 +2015,6 @@ public class RoomsController( MaxPlayers = s.MaxPlayers, Accessibility = room.Accessibility, UnitySceneId = s.RoomSceneLocationId, - CurrentSave = new - { - SubRoomDataSaveId = s.StudioSubRoomDataSaveId ?? 0L, - SubRoomId = (long)s.OrderIndex, - UnityAssetId = s.StudioUnityAssetId, - SubRoomUnityAssetId = s.StudioUnityAssetId, - CreatedByAccountId = room.CreatorPlayerId, - ReferencedUnityAssetIds = Array.Empty(), - DataBlob = dataBlob, - DataBlobHash = (string?)null, - PersistenceVersion = saveVersion, - OMVersion = 0, - SavedByAccountId = room.CreatorPlayerId, - SavedOnPlatform = 0, - SavedOnDeviceClass = 2, - Description = string.Empty, - ModerationState = 0, - CreatedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt) - .ToString("yyyy-MM-ddTHH:mm:ssZ"), - UgcSubVersion = saveVersion, - UnityAssetHash = string.Empty, - UnityAsset = unityAssetParent, - UnityAssetFilename = unityAsset?.Filename ?? string.Empty, - BakedUnityAssets = bakedAssets, - UnitySubAssets = Array.Empty(), - }, - LastModeratedSaveModerationState = 0, - DefaultMatchmakingPolicy = 0, - ShouldAutoStageSaves = true, - StagedSubRoomDataSaveId = (long?)null, }; }).ToArray() : new object[] @@ -1944,25 +2031,18 @@ public class RoomsController( RoomId = room.Id, Name = "Home", UnityAssetId = string.Empty, - SubRoomUnityAssetId = string.Empty, UnityAsset = string.Empty, UnityAssetHash = string.Empty, - DataBlob = dataBlobName, + // Same rule as the populated branch: honour the override, + // so a dorm with no scene rows still points at that + // player's own save rather than a per-room name. + DataBlob = primarySubRoomDataBlob, DataBlobHash = (string?)null, DataSavedAt = updatedAt, IsSandbox = false, MaxPlayers = 8, Accessibility = room.Accessibility, UnitySceneId = room.LocationReplicationId, - CurrentSave = new - { - SubRoomDataSaveId = 0L, - SubRoomId = 0L, - UnityAssetId = string.Empty, - SubRoomUnityAssetId = string.Empty, - ReferencedUnityAssetIds = Array.Empty(), - DataBlob = dataBlobName, - }, }, }; @@ -1980,7 +2060,6 @@ public class RoomsController( { Type = RoomTagTypeForWire(t), Tag = t, - IsPrimaryGenre = false, }) .ToArray(); @@ -2004,8 +2083,6 @@ public class RoomsController( SupportsQuest2 = true, SupportsMobile = room.SupportsMobile, SupportsJuniors = room.AllowsJuniors, - AllowNewUsers = room.AllowNewUsers, - AllowsNewUsers = room.AllowNewUsers, MinLevel = room.MinLevel, CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt) .ToString("yyyy-MM-ddTHH:mm:ssZ"), @@ -2017,39 +2094,13 @@ public class RoomsController( VisitCount = room.VisitCount, }, IsDorm = room.IsDormRoom, - IsStudioRoom = room.IsStudioRoom, - IsRoomLinkedToRecRoomStudio = room.IsRoomLinkedToRecRoomStudio, - StudioSessionId = room.StudioSessionId, CloningAllowed = room.CloningAllowed, DisableMicAutoMute = room.DisableMicAutoMute, DisableRoomComments = false, EncryptVoiceChat = false, LoadScreenLocked = false, - // Studio/UGC version family. The 2023 client reads UgcVersion to - // decide the asset-bundle version it requests - // (unity_assets/{id}/{target}/{UgcVersion}) and PersistenceVersion - // to recognise a baked Studio room at all. Matches the real - // RecRocks room.json (UgcVersion=1, PersistenceVersion=41). For a - // non-Studio room these stay at the legacy 0 so behaviour is - // unchanged. PersistenceVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0, - UgcVersion = room.IsStudioRoom ? StudioUgcVersion : 0, - UgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0, - MinUgcSubVersion = room.IsStudioRoom ? StudioPersistenceVersion : 0, IsDeveloperOwned = room.IsStudioRoom || HasRoomTag(room, "developer"), - IsJuniorCreated = false, - IsRecRoomApproved = false, - ExcludeFromLists = false, - BoostCount = 0, - RestrictedCircuitsAllowListNames = Array.Empty(), - PublishStateAvailability = new - { - CanSaveAsBeta = false, - CanSaveAsUpdate = true, - AvailableUpdateTokenCount = 3, - NextAvailableUpdateDateTimeUtc = (string?)null, - }, - PublishState = 0, MaxPlayerCalculationMode = room.MaxPlayerCalculationMode, SubRooms = subRooms, Roles = wireRoles, @@ -2057,6 +2108,39 @@ public class RoomsController( PromoImages = JsonStringArrayOrEmpty(room.PromoImagesJson), PromoExternalContent = JsonArrayOrEmpty(room.PromoExternalContentJson), Tags = tags, + // The client's room-details reader (GLEGPFFPDBE) registers these + // five alongside the rest. A registered slot wants a value, and + // leaving one absent is not the same as sending null — it is what + // makes the reader throw mid-deserialise on a response the server + // already answered 200 to. Kept last so the additions are visible + // against the reader's own list; key ORDER does not matter to a + // name-keyed reader, presence does. + // DataBlob / DataBlobHash are deliberately NOT sent at room level, + // even though the reader registers them. + // + // The room a player actually loads is chosen per INSTANCE: the + // matchmake response carries the blob (a dorm's is per-player, + // dorm_p{id}_v*.dat), and each sub-room carries its own. Adding a + // room-level name on top gave the client a second, room-scoped + // answer for the same question, and it loaded that one — a file + // that for a dorm was never written — then rejected the room with + // "the data in the room you are trying to load is corrupt". + // + // A registered slot does NOT oblige us to fill it: Newtonsoft + // leaves an absent key at its default, which is the state every + // room loaded fine in. Sending it is what broke the dorm, so the + // absence here is the fix, not an omission to tidy up later. + // The room-level cap is the room's own advertised capacity, which is + // what RoomEntity.MaxCapacity exists for and what matchmaking hands + // to RoomInstance.MaxCapacity. Each sub-room carries its own + // MaxPlayers in the SubRooms array — they are separate settings, so + // this must not be read off sub-room 0. + MaxPlayers = room.MaxCapacity, + // ToxMod is Rec Room's hosted voice moderation. There is no such + // service here, so it is off — the client only uses this to decide + // whether to show the indicator. + ToxmodEnabled = false, + RankingContext = (object?)null, }; } @@ -2186,6 +2270,109 @@ public class RoomsController( public async Task CreatedByOther(long otherPlayerId) => Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList()); + /// Per-player showcase curation, stored in the existing generic + /// player-settings table as a CSV of room ids. Colon-namespaced to match + /// the other server-owned keys (settings:partyinvite). + private const string ShowcaseSettingKey = "rooms:showcase"; + private const int ShowcaseMaxRooms = 50; + + /// GET showcase/{accountId} — the profile "Showcase Rooms" + /// carousel (RoomListModel.JMGLGEKHHAK.PlayerShowcaseRooms = 7, + /// dump.cs:2042498, driven by + /// AccountModelController.RoomsShowcaseLinkImpl at dump.cs:2113041). + /// Distinct from PlayerCreatedRooms = 5, which is the + /// rooms/createdby/{id} list above. + /// + /// Verb. NLDBPDCNNCF.EJPNHLIJNPM formats the literal + /// "showcase/{0}" (NLDBPDCNNCF.txt ISIL 021 at :4484) and moves the + /// HTTPMethods ordinal 0 (= GET) into rdx immediately before the + /// dispatch call (ISIL 031/032 at :4494-4495). No cmov, so GET is the only + /// verb. The path arg is boxed as System.Int32 (ISIL 017) — the + /// account id. + /// + /// Response shape. The issuing method's return type is the + /// contract: Task<List<System.Int64>> + /// (NLDBPDCNNCF.txt:4422) — a BARE JSON array of room ids, not Room + /// objects and not a {Results,TotalResults} container. The Rooms the + /// UI renders are a purely client-side projection: the cache entry + /// <GetRoomsShowcase>b__0 is typed + /// Task<IReadOnlyList<Int64>> + /// (IBEOONPEELF_NestedType_HHDHJPIJBJB.txt:14) and the outer + /// IBEOONPEELF.EJPNHLIJNPM resolves those ids through the room cache + /// to FGLDKEJLAKB<IReadOnlyList<NEMINAEBALC>> + /// (IBEOONPEELF.txt:8952) before RoomListModel consumes it + /// (RoomListModel.txt ISIL 742 at :5819). + /// + /// Backing data. showcase/{0} is the only showcase + /// literal in the binary — the 2023 client can read the list but never + /// writes it (curation lived on the website), so the curated order is kept + /// in the existing per-player settings store under + /// and is writable through + /// PUT /settings/v1/{accountId} with no new schema. Curated ids are + /// re-validated against Rooms on every read so a room that has since been + /// archived or privatised drops off the profile, and the curated order is + /// preserved. A player who never curated one falls back to the rooms they + /// actually created, most prominent first — the showcase the carousel is + /// there to display. + /// + /// Unauthenticated on purpose: this is another player's profile + /// surface, and the sibling rooms/createdby/{id} is open too. The + /// owner viewing their own profile additionally sees their non-public + /// picks; everyone else sees public rooms only. + [HttpGet("showcase/{accountId:long}")] + // roomserver/ twin: EJPNHLIJNPM lives on NLDBPDCNNCF, whose requests all + // arrive with the roomserver/ prefix in this deployment + // (docs/recroom-2023-room-save.md:83-93) — same reason every other + // NLDBPDCNNCF route in this file is registered twice. + [HttpGet("roomserver/showcase/{accountId:long}")] + public async Task RoomsShowcase(long accountId) + { + var viewerIsOwner = CurrentPlayerId == accountId; + + var curatedCsv = await db.PlayerSettings.AsNoTracking() + .Where(s => s.PlayerId == accountId && s.Key == ShowcaseSettingKey) + .Select(s => s.Value) + .FirstOrDefaultAsync(); + + var curatedIds = (curatedCsv ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(v => long.TryParse(v, out var id) ? id : 0L) + .Where(id => id > 0) + .Distinct() + .Take(ShowcaseMaxRooms) + .ToList(); + + if (curatedIds.Count > 0) + { + var visible = await db.Rooms.AsNoTracking() + .Where(r => curatedIds.Contains(r.Id) + && r.State == 0 + && !r.IsDormRoom + && !r.HiddenFromBrowse + && (r.Accessibility == 1 || viewerIsOwner)) + .Select(r => r.Id) + .ToListAsync(); + var visibleSet = visible.ToHashSet(); + // Curated order is the display order — filter, don't re-sort. + var ordered = curatedIds.Where(visibleSet.Contains).ToList(); + if (ordered.Count > 0) return Ok(ordered); + } + + var fallback = await db.Rooms.AsNoTracking() + .Where(r => r.CreatorPlayerId == accountId + && r.State == 0 + && !r.IsDormRoom + && !r.HiddenFromBrowse + && (r.Accessibility == 1 || viewerIsOwner)) + .OrderByDescending(r => r.HotScore) + .ThenByDescending(r => r.VisitCount) + .ThenByDescending(r => r.UpdatedAt) + .Select(r => r.Id) + .Take(ShowcaseMaxRooms) + .ToListAsync(); + return Ok(fallback); + } + [HttpGet("api/rooms/v1/visitedby/me")] [HttpGet("api/rooms/v2/visitedby/me")] [HttpGet("api/rooms/v3/visitedby/me")] @@ -2304,8 +2491,31 @@ public class RoomsController( [HttpGet("api/rooms/v2/myrecent")] public IActionResult MyOtherTabs() => Ok(Array.Empty()); + /// Per-(player, room) persistence blob, base64. Lives in the + /// generic key/value table — the same + /// "no schema change needed" trick MatchPlayerController uses for its + /// matchmaking prefs (MatchPlayerController.cs:213-217). The Value column + /// is declared MaxLength(1024) but SQLite stores it as unconstrained + /// TEXT and EF Core does not run DataAnnotations validation on SaveChanges, + /// so oversized blobs round-trip intact. + private static string PlayerRoomDataKey(long roomId) => $"roomplayerdata:{roomId}"; + + /// Fallback blob handed to a player who has never saved anything + /// in this room. CAE= is a one-field protobuf the client parses as + /// "empty, version 1"; a truly empty string makes the 2023 reader throw. + private const string DefaultPlayerRoomData = "CAE="; + + /// GET rooms/{roomId}/playerdata/me — the per-room player + /// blob (CircuitsV2 "player persistence"). Client contract is + /// Task<ILKMFMCOPPO> whose reader requires the key + /// Data (OPNPNPMKLGI.txt ISIL 034/060); the request is issued from + /// NLDBPDCNNCF (NLDBPDCNNCF_NestedType_IGENGEIMGMJ.txt:169), so it arrives + /// with the roomserver/ prefix — the bare-only registration 404'd. + /// This used to return a hardcoded CAE= for everyone; it now returns + /// whatever last stored. [HttpGet("player_room_data/{roomId:long}")] [HttpGet("rooms/{roomId:long}/playerdata/me")] + [HttpGet("roomserver/rooms/{roomId:long}/playerdata/me")] [Authorize] public async Task PlayerDataForMe(long roomId) { @@ -2326,9 +2536,15 @@ public class RoomsController( .Select(v => new { v.VisitCount, v.FirstVisitAt, v.LastVisitAt }) .FirstOrDefaultAsync(); + var key = PlayerRoomDataKey(roomId); + var stored = await db.PlayerSettings.AsNoTracking() + .Where(s => s.PlayerId == pid.Value && s.Key == key) + .Select(s => s.Value) + .FirstOrDefaultAsync(); + return Ok(new { - Data = "CAE=", + Data = string.IsNullOrEmpty(stored) ? DefaultPlayerRoomData : stored, RoomId = room?.Id ?? roomId, PlayerId = pid.Value, Favorite = favorited, @@ -2348,6 +2564,71 @@ public class RoomsController( }); } + public sealed class PlayerRoomDataRequest { public string? Data { get; set; } } + + /// PUT rooms/{roomId}/playerdata/me — persist the caller's + /// per-room blob. Client method NLDBPDCNNCF.LIHFBBCDAGO(long, string) + /// ("save player room data") moves verb ordinal 3 = PUT into r8 and posts a + /// single form field data + /// (NLDBPDCNNCF.txt ISIL 045/061/065 around :2566-2586; form key at + /// NLDBPDCNNCF_NestedType_ANPKFGHOOAJ.txt ISIL 015). Its return type is the + /// non-generic Task, so no response body is parsed. + /// + /// Only the GET was registered before, so every save 405'd and per-room + /// player state never persisted. + [HttpPut("rooms/{roomId:long}/playerdata/me")] + [HttpPost("rooms/{roomId:long}/playerdata/me")] + [HttpPut("roomserver/rooms/{roomId:long}/playerdata/me")] + [HttpPost("roomserver/rooms/{roomId:long}/playerdata/me")] + [HttpPut("player_room_data/{roomId:long}")] + [Authorize] + public async Task SavePlayerDataForMe( + long roomId, + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] PlayerRoomDataRequest req) + { + var pid = CurrentPlayerId; + if (pid is null) return Unauthorized(); + + var data = req.Data ?? string.Empty; + var key = PlayerRoomDataKey(roomId); + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == pid.Value && s.Key == key); + if (row is null) + { + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = pid.Value, Key = key, Value = data, + }); + } + else + { + row.Value = data; + } + await db.SaveChangesAsync(); + + // The client ignores the body (non-generic Task); the echo is for + // curl/admin debugging only. + return Ok(new { Result = 0, RoomId = roomId, PlayerId = pid.Value, Data = data }); + } + + /// GET rooms/requiring/{restriction} — the restricted-rooms + /// list. The path segment is mode.ToString().ToLower() + /// (Matchmaking.txt ISIL 067-072 around :21263, concat onto the literal + /// "rooms/requiring/" at :21264). + /// + /// Element type is String, not Int64 — verified, do not "fix" this + /// to room ids. The issuing method + /// Matchmaking.POIMPHMONON(IOCOJFJEDCM) (Matchmaking.txt:21030) + /// resolves its promise as FGLDKEJLAKB<List<String>> + /// (typeof load at Matchmaking.txt:21327, continuation delegate + /// Action<List<String>> at :21315) and hands the payload to + /// <RequestRoomRestrictions>b__2(List<String> rooms) + /// (Matchmaking_NestedType_PMNEOAMKLJL.txt:14), which forwards it to + /// Matchmaking.IINEGHJJBAN(IEnumerable<String>, IOCOJFJEDCM) + /// (:20758) where the strings become the KEYS of the restriction map + /// (List<IOCOJFJEDCM> per key, ISIL 069-079 at :20963-20974). + /// So a bare array of room NAMES is the correct wire shape. Failures log + /// "Failed to get restricted rooms list: " (Matchmaking.txt:21306). [HttpGet("rooms/requiring/{restriction}")] [HttpGet("roomserver/rooms/requiring/{restriction}")] [Authorize] @@ -2507,8 +2788,13 @@ public class RoomsController( /// GET api/rooms/v1/agRoomIds — list of every /// seeded AG / RR-Original room id. Used by the watch to bias the /// room browser toward official rooms first. + // Bare array of Int64 — the 2023 issuing method GBONEKLGFFK returns + // Task> (NLDBPDCNNCF.txt:1886, literal at :1929). The + // roomserver/ twin is what that client actually requests (same prefix + // rule as rooms/base, docs/recroom-2023-room-save.md:83-93). [HttpGet("api/rooms/v1/agRoomIds")] [HttpGet("rooms/rro_ids")] + [HttpGet("roomserver/rooms/rro_ids")] public async Task AgRoomIds() => Ok(await rooms.AgRoomIdsAsync()); @@ -2532,7 +2818,9 @@ public class RoomsController( /// api/rooms/v3/featured). Returning the /// shape here throws KeyNotFoundException on /// the watch's group-id lookup. + // Same roomserver/ prefix requirement as rooms/base. [HttpGet("featuredrooms/current")] + [HttpGet("roomserver/featuredrooms/current")] public async Task FeaturedRoomsCurrent() { var ids = await rooms.FeaturedAgRoomIdsAsync(12); @@ -2540,10 +2828,22 @@ public class RoomsController( .Where(r => ids.Contains(r.Id)) .ToListAsync(); var byId = featuredRooms.ToDictionary(r => r.Id); + // The featured-tile reader looks for RoomName, which ToWireRoom does not + // emit — every tile's caption came back null. Re-project the wire object + // into a dictionary so the extra key sits alongside the real ones rather + // than nesting them. var wireRooms = ids - .Select(id => byId.TryGetValue(id, out var room) ? RoomService.ToWireRoom(room) : null) - .Where(room => room is not null) - .Cast() + .Where(byId.ContainsKey) + .Select(id => + { + var room = byId[id]; + var wire = RoomService.ToWireRoom(room); + var map = new Dictionary(); + foreach (var prop in wire.GetType().GetProperties()) + map[prop.Name] = prop.GetValue(wire); + map["RoomName"] = room.Name; + return (object)map; + }) .ToList(); return Ok(new { @@ -2584,19 +2884,31 @@ public class RoomsController( /// We accept both POST and PUT because the watch's ISIL /// doesn't expose the explicit HTTPMethod literal at this call /// site — covering both is cheap and means we can't get bitten - /// by the wrong one. + /// by the wrong one. + /// + /// 2023 client: NLDBPDCNNCF.LHNDKOJNKBE + /// ("change room name", NLDBPDCNNCF.txt:5069) moves verb ordinal 3 = PUT, + /// sends the lowercase form key name + /// (NLDBPDCNNCF_NestedType_LMADOAEHOPP.txt ISIL 015) and returns + /// Task<FGCPNAACHIK> — the FULL room-details object, not a + /// status wrapper. It also carries the roomserver/ prefix like every + /// other room mutation from that client + /// (docs/recroom-2023-room-save.md:83-93), so the bare-only routes 404'd. + /// The old signature mixed [FromForm] with a [FromBody] + /// parameter, which under [ApiController] makes the form PUT 415 + /// before the handler runs — hence the shared FormOrJsonModelBinder. public sealed class RenameBody { public string? Name { get; set; } } [HttpPost("rooms/{roomId:long}/name")] [HttpPut("rooms/{roomId:long}/name")] + [HttpPost("roomserver/rooms/{roomId:long}/name")] + [HttpPut("roomserver/rooms/{roomId:long}/name")] [Authorize] public async Task RenameRoom( long roomId, - [FromForm(Name = "Name")] string? nameForm, - [FromForm(Name = "name")] string? nameFormLower, - [FromBody] RenameBody? body) + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body) { - var newName = nameForm ?? nameFormLower ?? body?.Name; + var newName = body?.Name; if (string.IsNullOrWhiteSpace(newName)) return BadRequest(new { Result = 1, error = "empty_name" }); newName = newName.Trim(); @@ -2624,35 +2936,24 @@ public class RoomsController( await db.SaveChangesAsync(); logger.LogInformation("[room-rename] room={Room} by={By} → '{Name}'", roomId, me, newName); - // Return the full updated Room DTO. The watch's - // PPENFJMFPNE deserialiser inherits from - // KLCOGEIGEBJ (the standard Room shape) so we can - // forward to the same v3-details payload builder by re-reading. - // BuildRoomDetailsV3Async is too heavy here — just return the - // minimal mutated subset; the watch UI re-fetches details - // shortly after for the room-info screen. - return Ok(new - { - Result = 0, - RoomId = room.Id, - Name = room.Name, - Description = room.Description, - ImageName = room.ImageName, - CreatorPlayerId = room.CreatorPlayerId, - CreatorAccountId = room.CreatorPlayerId, - State = room.State, - Accessibility = room.Accessibility, - IsAGRoom = room.IsAGRoom, - IsRRO = false, - IsDormRoom = room.IsDormRoom, - }); + // Full room details, not the old minimal {Result,RoomId,Name,…} object: + // the 2023 issuing method returns Task (NLDBPDCNNCF.txt:5069) + // and that reader needs Room/Scenes/CoOwners/Stats — the trimmed payload + // made a successful rename surface as a parse failure in the client. + return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } /// POST rooms/{roomId}/subrooms/{subRoomId}/name — /// rename a scene/sub-room within a multi-scene room. Watch path: /// EJDCNGBEICB.PAENPJCDLNJ(long roomId, long subRoomId, string newName). /// Updates the matching after - /// verifying ownership and intra-room uniqueness. + /// verifying ownership and intra-room uniqueness. + /// + /// 2023 client: NLDBPDCNNCF.EJOHFBMFEGB + /// ("change subroom name", NLDBPDCNNCF.txt:8694) is verb 3 = PUT with form + /// key name and returns Task<FGCPNAACHIK>. Same + /// [FromForm]+[FromBody] 415 hazard as the room rename above, so it binds + /// through the shared FormOrJsonModelBinder and echoes full details. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] @@ -2661,11 +2962,9 @@ public class RoomsController( public async Task RenameSubroom( long roomId, long subRoomId, - [FromForm(Name = "Name")] string? nameForm, - [FromForm(Name = "name")] string? nameFormLower, - [FromBody] RenameBody? body) + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body) { - var newName = nameForm ?? nameFormLower ?? body?.Name; + var newName = body?.Name; if (string.IsNullOrWhiteSpace(newName)) return BadRequest(new { Result = 1, error = "empty_name" }); newName = newName.Trim(); @@ -2698,19 +2997,7 @@ public class RoomsController( await db.SaveChangesAsync(); logger.LogInformation("[subroom-rename] room={Room} sub={Sub} by={By} → '{Name}'", roomId, subRoomId, me, newName); - return Ok(new - { - Result = 0, - RoomId = roomId, - RoomSceneId = scene.OrderIndex, - SubRoomId = scene.OrderIndex, - Name = scene.Name, - scene.DataBlobName, - RoomSceneLocationId = scene.RoomSceneLocationId, - scene.IsSandbox, - scene.MaxPlayers, - scene.CanMatchmakeInto, - }); + return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } /// POST api/rooms/v1/cheer — cheer a room (body @@ -2757,9 +3044,21 @@ public class RoomsController( } // ── Bare-path interactionby/me toggles ───────────────────────────── + // + // Verbs come straight from the 2023 client's four one-liner methods on + // NLDBPDCNNCF (each a non-generic Task, so no response body is parsed): + // "cheer room" :13467 r8 = 3 (PUT) + // "uncheer room" :13550 r8 = 4 (DELETE) + // "favorite room" :13633 r8 = 3 (PUT) + // "unfavorite room" :13716 r8 = 4 (DELETE) + // All four are issued from NLDBPDCNNCF, so they arrive with the + // roomserver/ prefix (docs/recroom-2023-room-save.md:83-93) — without the + // twins the cheer/favorite buttons 404'd and failed silently. [HttpPost("rooms/{roomId:long}/interactionby/me/cheer")] [HttpPut("rooms/{roomId:long}/interactionby/me/cheer")] + [HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/cheer")] + [HttpPut("roomserver/rooms/{roomId:long}/interactionby/me/cheer")] [Authorize] public async Task BareCheer(long roomId) { @@ -2769,6 +3068,7 @@ public class RoomsController( } [HttpDelete("rooms/{roomId:long}/interactionby/me/cheer")] + [HttpDelete("roomserver/rooms/{roomId:long}/interactionby/me/cheer")] [Authorize] public async Task BareUncheer(long roomId) { @@ -2779,6 +3079,8 @@ public class RoomsController( [HttpPost("rooms/{roomId:long}/interactionby/me/favorite")] [HttpPut("rooms/{roomId:long}/interactionby/me/favorite")] + [HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/favorite")] + [HttpPut("roomserver/rooms/{roomId:long}/interactionby/me/favorite")] [Authorize] public async Task BareFavorite(long roomId) { @@ -2788,6 +3090,7 @@ public class RoomsController( } [HttpDelete("rooms/{roomId:long}/interactionby/me/favorite")] + [HttpDelete("roomserver/rooms/{roomId:long}/interactionby/me/favorite")] [Authorize] public async Task BareUnfavorite(long roomId) { @@ -3038,7 +3341,6 @@ public class RoomsController( { Type = RoomTagTypeForWire(t), Tag = t, - IsPrimaryGenre = false, }) .ToList(); @@ -3294,10 +3596,20 @@ public class RoomsController( } /// POST rooms/{id}/subrooms — append a new scene. - /// Owner-gated. OrderIndex = max+1, Name unique within room. + /// Owner-gated. OrderIndex = max+1, Name unique within room. + /// + /// 2023 client: NLDBPDCNNCF.CMHBCOIBDME ("add new + /// subroom", NLDBPDCNNCF.txt:9396) is verb 2 = POST with a single form key + /// name (NLDBPDCNNCF_NestedType_AIPHLHBILDN.txt ISIL 015) and returns + /// Task<FGCPNAACHIK>. It needs the roomserver/ twin, a binder + /// that accepts the form body ([FromBody] 415s it), and full room + /// details in the response instead of the scene row. [HttpPost("rooms/{roomId:long}/subrooms")] + [HttpPost("roomserver/rooms/{roomId:long}/subrooms")] [Authorize] - public async Task AddSubRoom(long roomId, [FromBody] CreateSubRoomRequest req) + public async Task AddSubRoom( + long roomId, + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); @@ -3324,10 +3636,18 @@ public class RoomsController( }; db.RoomScenes.Add(scene); await db.SaveChangesAsync(); - return Ok(SceneWire(scene)); + return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } + /// DELETE rooms/{id}/subrooms/{sub}. The 2023 caller + /// NLDBPDCNNCF.HGNGMIMJFJL ("delete subroom", NLDBPDCNNCF.txt:9827) + /// moves verb ordinal 4 = DELETE and returns + /// Task<FGCPNAACHIK>; the only registration here used to be the + /// bare one (the roomserver/ twin at the top of the file is the GET), so + /// deleting a subroom 404'd, and the {Result:0} body was not the + /// details object the client re-parses. [HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")] + [HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")] [Authorize] public async Task DeleteSubRoom(long roomId, long subRoomId) { @@ -3341,20 +3661,69 @@ public class RoomsController( if (scene.OrderIndex == 0) return BadRequest(new { error = "cannot_delete_entry_scene" }); db.RoomScenes.Remove(scene); await db.SaveChangesAsync(); - return Ok(new { Result = 0 }); + return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } - public sealed class SubRoomIntRequest { public int? Value { get; set; } } - public sealed class SubRoomBoolRequest { public bool? Value { get; set; } } + // Subroom mutation DTOs. Property names mirror the LOWERCASE form keys the + // 2023 client actually sends (FormOrJsonModelBinder matches case-insensitively): + // maxPlayers NLDBPDCNNCF_NestedType_BLNOGFGHIIF.txt ISIL 020 + // accessibility NLDBPDCNNCF_NestedType_GNCLJBNJEKP.txt ISIL 020 + // name/accessibility/maxPlayers ..._JEEACJHABAF.txt ISIL 021/035/048 + // newRoomId NLDBPDCNNCF_NestedType_COIOGADEBKJ.txt ISIL 020 + // The older {Value:…} DTOs matched no client key at all, and being bound + // [FromBody] they 415'd the form body before the handler ran. + public sealed class SubRoomMaxPlayersRequest { public int? MaxPlayers { get; set; } } + + // Accessibility is typed string, not int, on purpose — the two client call + // sites disagree about how they stringify it. The standalone + // "change subroom accessibility" closure boxes the value as + // typeof(JHAOFLCJNAL) (GNCLJBNJEKP.txt ISIL 015), and the form writer's + // object overload falls through to a virtual ToString() for anything that + // isn't String/DateTime (BNDIAONDFFF.txt ISIL 107-110) — i.e. an enum + // MEMBER NAME goes on the wire. The bulk "modify" closure boxes the very + // same value as typeof(System.Int32) (JEEACJHABAF.txt ISIL 031) and sends a + // number. ParseSubRoomAccessibility below accepts both spellings. + public sealed class SubRoomAccessibilityRequest { public string? Accessibility { get; set; } } + public sealed class SubRoomModifyRequest { public string? Name { get; set; } + public string? Accessibility { get; set; } public int? MaxPlayers { get; set; } public bool? IsSandbox { get; set; } public bool? CanMatchmakeInto { get; set; } public string? RoomSceneLocationId { get; set; } } - public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } } + + public sealed class SubRoomMoveRequest + { + /// Target ROOM for the scene — the 2023 "move subroom" flow + /// re-parents a scene into another room. Nullable<Int64> on the + /// wire (NLDBPDCNNCF.MGGALMIAMBG, NLDBPDCNNCF.txt:9659). + public long? NewRoomId { get; set; } + + /// Legacy in-room reorder used by the admin/import tooling. + /// Not something the 2023 client sends. + public int? NewIndex { get; set; } + } + + /// One row of the per-subroom role-permission matrix the 2023 + /// client PUTs to subrooms/{sub}/permissions. Keys verified in the + /// writer HDOJMEBCAGP.txt ISIL 050/069/085/101/117. + public sealed class SubRoomPermissionEntry + { + // Field order + CLR types come from the BNLPBMJJOMM constructor + // (BNLPBMJJOMM.txt:126): (String Permission, OMMBGJMJJPN Role, + // Boolean Override, FEOJFGECKGH Type, String Value). The two enum + // members serialize as numbers (the role writer boxes them + // System.Int32 — BFKNABDHMGG.txt ISIL 015), hence int? here. + public string? Permission { get; set; } + public int? Role { get; set; } + public bool? Override { get; set; } + public int? Type { get; set; } + public string? Value { get; set; } + } + public sealed class PublishSubRoomSaveRequest { public long? SubRoomDataSaveId { get; set; } @@ -3377,29 +3746,94 @@ public class RoomsController( [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")] [Authorize] - public Task SubRoomMaxPlayers(long roomId, long subRoomId, [FromBody] SubRoomIntRequest req) => - MutateScene(roomId, subRoomId, s => { if (req.Value is int v) s.MaxPlayers = Math.Max(1, v); }); + public Task SubRoomMaxPlayers( + long roomId, + long subRoomId, + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMaxPlayersRequest req) => + MutateScene(roomId, subRoomId, s => { if (req.MaxPlayers is int v) s.MaxPlayers = Math.Max(1, v); }); + /// PUT rooms/{id}/subrooms/{sub}/accessibility. Client: + /// NLDBPDCNNCF.OAINKEAFPGE(long, long, JHAOFLCJNAL) + /// (NLDBPDCNNCF.txt:9032, verb 3), form key accessibility, response + /// Task<FGCPNAACHIK>. We only model the matchmaking bit + /// (), so Private collapses to + /// "not a matchmaking target" and everything else to "is one". [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")] [Authorize] - public Task SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) => - MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; }); + public Task SubRoomAccessibility( + long roomId, + long subRoomId, + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomAccessibilityRequest req) => + MutateScene(roomId, subRoomId, s => + { + if (ParseSubRoomAccessibility(req.Accessibility) is int a) + s.CanMatchmakeInto = a != AccessibilityPrivate; + }); + /// PUT rooms/{id}/subrooms/{sub}/permissions — the + /// role-permission matrix. Client: + /// NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList<BNLPBMJJOMM>) + /// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT + /// form-encode: the closure calls BNDIAONDFFF.FJLLPHFOOJJ(String) + /// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string + /// (BNDIAONDFFF.txt:275, mov dword ptr [rbx+38h],4) — i.e. a raw + /// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON + /// deserialize into the List). + /// + /// Scope note — deliberately not persisted. DorkNet has no + /// per-subroom permission storage: has no + /// column for it and there is no permissions table. Adding one means an + /// entity + DbContext + migration, which is outside this file. Until then + /// the matrix is accepted, logged, and the room details echoed back so the + /// client's promise resolves instead of 415-ing; the values do not survive + /// a round-trip. The old handler silently bound an unrelated DTO whose keys + /// the client never sends, which was strictly worse. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")] [Authorize] - public Task SubRoomPermissions(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest? req) => - MutateScene(roomId, subRoomId, s => - { - if (req?.CanMatchmakeInto is bool canMatchmakeInto) s.CanMatchmakeInto = canMatchmakeInto; - if (req?.MaxPlayers is int maxPlayers) s.MaxPlayers = Math.Max(1, maxPlayers); - if (req?.IsSandbox is bool isSandbox) s.IsSandbox = isSandbox; - }); + public async Task SubRoomPermissions( + long roomId, + long subRoomId, + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List req) + { + var pid = this.RequireCurrentPlayerId(); + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); + if (room is null) return NotFound(); + if (room.CreatorPlayerId != pid) return Forbid(); + var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId); + if (scene is null) return NotFound(); + await db.SaveChangesAsync(); + + logger.LogWarning( + "[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)", + roomId, subRoomId, pid, req?.Count ?? 0); + + return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); + } + + // JHAOFLCJNAL member values. The numeric leg is verified (the "modify" + // closure boxes the same value as System.Int32 — JEEACJHABAF.txt ISIL 031); + // the NAMES are inferred from the room-accessibility enum in the nearest + // dump with real names (RecRoom-2023.06.21 dump.cs:1293997-1294004 + // MKDFFJGGKDL { Private = 0, Public = 1, Unlisted = 2 }), which is the enum + // Enum.ToString() would spell out on the standalone accessibility call. + private const int AccessibilityPrivate = 0; + + private static int? ParseSubRoomAccessibility(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + var value = raw.Trim(); + if (int.TryParse(value, out var numeric)) return numeric; + if (value.StartsWith("priv", StringComparison.OrdinalIgnoreCase)) return 0; + if (value.StartsWith("pub", StringComparison.OrdinalIgnoreCase)) return 1; + if (value.StartsWith("unlist", StringComparison.OrdinalIgnoreCase)) return 2; + return null; + } [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")] @@ -3434,7 +3868,9 @@ public class RoomsController( scene.DataModifiedAt = DateTime.UtcNow; room.UpdatedAt = scene.DataModifiedAt; await db.SaveChangesAsync(); - return Ok(SceneWire(scene)); + // "restore subroom save" returns Task (NLDBPDCNNCF.txt:10476), + // so the scene row alone made a successful restore read as a failure. + return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } private async Task ReadPublishSubRoomSaveRequestAsync() @@ -3516,34 +3952,68 @@ public class RoomsController( }; db.RoomScenes.Add(clone); await db.SaveChangesAsync(); - return Ok(SceneWire(clone)); + // Task ("clone subroom", NLDBPDCNNCF.txt:9554) — the scene + // row is not what the client re-parses. + return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } // Subroom settings save POSTs here on the roomserver host ("failed to // modify room" = the bare-only routes 404'd). Same roomserver/ prefix as // the sibling subroom mutations. + // + // Body is form-urlencoded name/accessibility/maxPlayers ("modify room", + // NLDBPDCNNCF.txt:10846 verb 3; keys at JEEACJHABAF.txt ISIL 021/035/048). + // [FromBody] rejected that with 415 before the handler ran, and the DTO had + // no 'accessibility' member at all, so the bulk subroom-settings save was a + // guaranteed failure. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")] [Authorize] - public Task ModifySubRoom(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest req) => + public Task ModifySubRoom( + long roomId, + long subRoomId, + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomModifyRequest req) => MutateScene(roomId, subRoomId, s => { if (!string.IsNullOrWhiteSpace(req.Name)) s.Name = req.Name.Trim(); if (req.MaxPlayers is int mp) s.MaxPlayers = Math.Max(1, mp); if (req.IsSandbox is bool sb) s.IsSandbox = sb; if (req.CanMatchmakeInto is bool cmi) s.CanMatchmakeInto = cmi; + if (ParseSubRoomAccessibility(req.Accessibility) is int a) + s.CanMatchmakeInto = a != AccessibilityPrivate; if (req.RoomSceneLocationId is not null) s.RoomSceneLocationId = req.RoomSceneLocationId; }); - /// POST rooms/{id}/subrooms/{sub}/move — reorder a - /// scene. Body NewIndex is the target OrderIndex; the in-between - /// rows shift to fill the gap. + /// POST rooms/{id}/subrooms/{sub}/move. + /// + /// The 2023 semantics are "move to another ROOM", not "reorder". + /// NLDBPDCNNCF.MGGALMIAMBG(long roomId, long subRoomId, + /// Nullable<Int64> newRoomId) ("move subroom", + /// NLDBPDCNNCF.txt:9659, verb 2 = POST) sends the single form key + /// newRoomId (COIOGADEBKJ.txt ISIL 013-020, boxed as + /// Nullable<Int64>) and returns + /// Task<FGCPNAACHIK>. The old handler bound + /// [FromBody] {NewIndex} — a key the client never sends, on a body + /// binding that 415s form posts — and reordered scenes in place instead. + /// + /// The caller must own BOTH rooms. The scene is re-parented to the end + /// of the target room's scene list and the source room's remaining scenes + /// are re-packed so OrderIndex stays a dense 0..n-1 sequence — the + /// wire RoomSceneId and matchmaking SubRoomId are that index. + /// Scene 0 is the room's entry scene and cannot be moved out. + /// newIndex (no client sends it) keeps the old in-room reorder for + /// the import tooling. [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")] + [HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")] [HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")] + [HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")] [Authorize] - public async Task MoveSubRoom(long roomId, long subRoomId, [FromBody] SubRoomMoveRequest req) + public async Task MoveSubRoom( + long roomId, + long subRoomId, + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req) { var pid = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); @@ -3553,19 +4023,69 @@ public class RoomsController( .Where(s => s.RoomId == roomId).OrderBy(s => s.OrderIndex).ToListAsync(); var moving = scenes.FirstOrDefault(s => s.OrderIndex == subRoomId); if (moving is null) return NotFound(); - var newIndex = Math.Clamp(req.NewIndex ?? 0, 0, scenes.Count - 1); - scenes.Remove(moving); - scenes.Insert(newIndex, moving); - // Recompute OrderIndex on the entire row set. The unique - // (RoomId, OrderIndex) index would clash if we wrote rows in - // place — flush a sentinel offset first, then settle to - // final indices. + + // `newRoomId` is Nullable on the wire and the form writer + // stringifies it with ToString(), which yields "" for the empty case — + // so an absent target arrives as a blank field, not a missing one. + // Treat "no target and no index" as a no-op; the old code fell through + // to `NewIndex ?? 0` and silently reordered the scene to the front. + if (req.NewRoomId is not long targetRoomId || targetRoomId == roomId) + { + if (req.NewIndex is not int requestedIndex) + return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); + + var newIndex = Math.Clamp(requestedIndex, 0, scenes.Count - 1); + scenes.Remove(moving); + scenes.Insert(newIndex, moving); + await RepackSceneOrderAsync(scenes); + return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); + } + + // Cross-room move — the path the 2023 client actually takes. + var target = await db.Rooms.FirstOrDefaultAsync(r => r.Id == targetRoomId); + if (target is null) return NotFound(new { error = "target_room_not_found", targetRoomId }); + if (target.CreatorPlayerId != pid) return Forbid(); + if (moving.OrderIndex == 0) + return BadRequest(new { error = "cannot_move_entry_scene" }); + + // Names are unique per room (RoomScenes index on (RoomId, Name)). + var movedName = moving.Name; + var suffix = 1; + while (await db.RoomScenes.AnyAsync(s => s.RoomId == targetRoomId && s.Name == movedName)) + { + suffix += 1; + movedName = $"{moving.Name} {suffix}"; + } + + var targetOrder = (await db.RoomScenes.Where(s => s.RoomId == targetRoomId) + .Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1; + moving.RoomId = targetRoomId; + moving.OrderIndex = targetOrder; + moving.Name = movedName; + moving.DataModifiedAt = DateTime.UtcNow; + room.UpdatedAt = moving.DataModifiedAt; + target.UpdatedAt = moving.DataModifiedAt; + await db.SaveChangesAsync(); + + // Re-pack the source room so its remaining scenes keep dense indices + // (same sentinel dance as the in-room reorder above). + await RepackSceneOrderAsync(scenes.Where(s => s.Id != moving.Id).ToList()); + logger.LogInformation("[subroom-move] scene={Scene} room={From}→{To} by={By}", + moving.Id, roomId, targetRoomId, pid); + return Ok(await BuildRoomServerDetailsWithRolesAsync(targetRoomId)); + } + + /// Rewrite to 0..n-1 in + /// list order. The unique (RoomId, OrderIndex) index would clash if rows + /// were written in place, so flush a sentinel offset first, then settle. + private async Task RepackSceneOrderAsync(List scenes) + { + if (scenes.Count == 0) return; const int sentinelOffset = 100_000; for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i + sentinelOffset; await db.SaveChangesAsync(); for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i; await db.SaveChangesAsync(); - return Ok(SceneWire(moving)); } /// POST roomserver/rooms/{id}/subrooms/{sub}/data — @@ -3638,6 +4158,11 @@ public class RoomsController( return null; } + /// Shared owner-gate + mutate + save for the subroom setting + /// endpoints. Responds with the FULL room details: every 2023 caller that + /// lands here (maxplayers / accessibility / modify) is typed + /// Task<FGCPNAACHIK>, so the old scene-row payload made a + /// successful mutation surface as a client-side parse failure. private async Task MutateScene(long roomId, long subRoomId, Action mutator) { var pid = this.RequireCurrentPlayerId(); @@ -3649,7 +4174,7 @@ public class RoomsController( mutator(scene); scene.DataModifiedAt = DateTime.UtcNow; await db.SaveChangesAsync(); - return Ok(SceneWire(scene)); + return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } private async Task GetOrCreateSceneForMutationAsync(RoomEntity room, long subRoomId) @@ -3674,25 +4199,21 @@ public class RoomsController( return scene; } - private static object SceneWire(RoomSceneEntity s) => new - { - Result = 0, - RoomId = s.RoomId, - RoomSceneId = s.OrderIndex, - SubRoomId = s.OrderIndex, - Name = s.Name, - s.DataBlobName, - RoomSceneLocationId = s.RoomSceneLocationId, - s.IsSandbox, - s.MaxPlayers, - s.CanMatchmakeInto, - }; + // SceneWire (the old {Result,RoomId,RoomSceneId,…} scene-row payload) is + // gone: every subroom mutation the 2023 client issues is typed + // Task and re-parses the full room details, so all of these + // handlers now go through BuildRoomServerDetailsWithRolesAsync. // ── Bare-path roles ──────────────────────────────────────────────── /// GET rooms/{id}/roles — account role DTOs consumed by - /// 2023's EFHPLDPNGIM deserializer. + /// 2023's EFHPLDPNGIM deserializer + /// (NLDBPDCNNCF.LAJDCNIMBAB → Task<List<EFHPLDPNGIM>>, + /// NLDBPDCNNCF.txt:2111, verb 0 = GET). Like every other NLDBPDCNNCF call it + /// arrives with the roomserver/ prefix, so the bare-only registration + /// 404'd the permissions screen's list fetch. [HttpGet("rooms/{roomId:long}/roles")] + [HttpGet("roomserver/rooms/{roomId:long}/roles")] public async Task RolesList(long roomId) { var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); @@ -3815,7 +4336,12 @@ public class RoomsController( return new StudioBundleInfo(saveId, target, version, name); } + /// GET rooms/{id}/roles/{playerId} — one role row + /// (NLDBPDCNNCF.PCFOIPELKHN → Task<EFHPLDPNGIM>, + /// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list + /// above. [HttpGet("rooms/{roomId:long}/roles/{playerId:long}")] + [HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")] public async Task RoleForPlayer(long roomId, long playerId) { var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); @@ -3830,10 +4356,17 @@ public class RoomsController( public sealed class GrantRoleRequest { public int? Role { get; set; } } - /// POST rooms/{id}/roles/{playerId} — owner grants - /// a role to a player. Auto-accepted (no invite step). + /// PUT rooms/{id}/roles/{playerId} — owner grants + /// a role to a player. Auto-accepted (no invite step). + /// NLDBPDCNNCF.LMIEIJMCEAB ("change room account role", + /// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key role carrying a + /// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full + /// FGCPNAACHIK details — which this already does. Only the + /// roomserver/ twin was missing. [HttpPut("rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("rooms/{roomId:long}/roles/{playerId:long}")] + [HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")] + [HttpPost("roomserver/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")] [HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")] @@ -3875,9 +4408,13 @@ public class RoomsController( /// POST rooms/{id}/roles/{playerId}/invite — same /// as grant but Accepted=false; the target's accept-invite flow /// flips the flag (separate endpoint, not yet exposed). For now - /// invited rows surface in RoomDetails.InvitedCoOwners etc. + /// invited rows surface in RoomDetails.InvitedCoOwners etc. + /// NLDBPDCNNCF.KIJDCLINOIC ("invite account to role", + /// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix. [HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")] + [HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")] + [HttpPost("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")] diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs index d0c5983..775dfb4 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -22,7 +22,10 @@ namespace DorkNet.Server.Controllers.API.Rooms.V2; /// [ApiController] [Authorize] -public class RoomsModerationController(DorkNetDbContext db) : ControllerBase +public class RoomsModerationController( + DorkNetDbContext db, + IObjectStorage storage, + ILogger logger) : ControllerBase { private const int PublicAccessibility = 1; private const long CanUseShareCamPermission = 1L << 18; @@ -244,6 +247,19 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase .ToListAsync(); return Ok(rows.Select(b => new { + // The 2023 banned-players reader (IBHAKOOKEEE) has EXACTLY three + // members — AccountId (Int32), BannedByAccountId (Int32?) and + // BanStartTime (DateTime) — registered at + // IsilDump/RecNet.Runtime/ECEBBLBCFKO.txt:279 / :306 / :330 (the + // property types come from IBHAKOOKEEE.txt:3/:83/:103). None of + // them overlapped the legacy Id/RoomId/BannedPlayerId keys, so + // the in-room "banned players" list rendered all-zero rows. + // AccountId is Int32 on the wire, so clamp the long id. + AccountId = ToClientAccountId(b.BannedPlayerId), + BannedByAccountId = ToClientAccountId(b.BannedByPlayerId), + BanStartTime = b.CreatedAt, + // Legacy keys kept for the 2020.12 watch — both readers ignore + // members they don't know. b.Id, b.RoomId, b.BannedPlayerId, @@ -255,28 +271,47 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase })); } + /// Account ids are Int64 server-side but Int32 in the 2023 + /// wire DTOs (IBHAKOOKEEE.AccountId is System.Int32). Clamp rather than + /// wrap so an out-of-range id is obviously wrong instead of aliasing + /// onto a real account. + private static int ToClientAccountId(long accountId) + => accountId > int.MaxValue + ? int.MaxValue + : accountId < int.MinValue + ? int.MinValue + : (int)accountId; + // ── Report ─────────────────────────────────────────────────────────── - public sealed class RoomReportRequest - { - public long RoomId { get; set; } - public int Category { get; set; } - public string? Message { get; set; } - } - + // The 2023 client posts the room report as x-www-form-urlencoded with + // PascalCase keys RoomId (Int64) / RoomKeyId (Int64?) / Details (string) / + // ReportCategory (Int32) — IsilDump/RecNet.Runtime/IBEOONPEELF.txt:23471 + // ("api/rooms/v2/report"), verb byte 2 = POST at :23473, field names at + // :23486 / :23499 / :23506 / :23521. A [FromBody] parameter makes + // ASP.NET reject the form POST with 415 BEFORE the handler runs (the same + // mechanism documented for clone at :963-968 below), so reports were never + // persisted and the client logged "Failed to report room" + // (IBEOONPEELF.txt:23537). Read form-or-JSON manually and keep the older + // Category/Message names as aliases for the 2020.12 watch. [HttpPost("api/rooms/v2/report")] - public async Task Report([FromBody] RoomReportRequest req) + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task Report() { - var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId); + var payload = await ReadPayloadAsync(); + var roomId = LongValue(payload, "RoomId", "RoomKeyId") ?? 0; + if (roomId <= 0) return BadRequest(new { error = "missing_room" }); + + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); - var msg = req.Message ?? string.Empty; + var msg = RawValue(payload, "Details", "Message") ?? string.Empty; db.Reports.Add(new ReportEntity { ReporterPlayerId = Me, TargetPlayerId = room.CreatorPlayerId, - TargetRoomId = req.RoomId, - RoomId = req.RoomId, - Category = req.Category, + TargetRoomId = roomId, + RoomId = roomId, + Category = IntValue(payload, "ReportCategory", "Category") ?? 0, Message = msg[..Math.Min(1000, msg.Length)], }); await db.SaveChangesAsync(); @@ -433,53 +468,98 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase }; } - // ── Bare-path per-field mutations (2020.12 watch URLs) ───────────── + // ── Bare-path per-field mutations (2020.12 watch + 2023 roomserver) ── + // + // Both clients emit per-field mutation URLs like + // PUT rooms/{id}/description rather than POST api/rooms/v2/modify with + // one field set. Each handler below shares the same RequireOwnedRoomAsync + // gate as /modify and returns the FULL room-details object via + // ApplyAndReturn (see the note there). + // + // Every handler reads the body itself instead of taking a [FromBody] + // parameter: both clients send x-www-form-urlencoded, and under + // [ApiController] a [FromBody] parameter makes ASP.NET reject a form body + // with 415 BEFORE the handler runs (mechanism documented at the clone + // handler below). // - // The 2020.12 client emits per-field mutation URLs like - // POST rooms/{id}/description rather than POST api/rooms/v2/modify - // with one field set. Each handler below shares the same - // RequireOwnedRoomAsync gate as /modify and returns the same - // ToWireRoom shape so the watch's CreateModifyRoomResponse - // deserializer reads cleanly. - - public sealed class BareDescriptionRequest { public string? Description { get; set; } } - public sealed class BareImageRequest { public string? ImageName { get; set; } } - public sealed class BareTagsRequest { public string? Tags { get; set; } public List? TagsList { get; set; } } - public sealed class BareIntRequest { public int? Value { get; set; } } - public sealed class BareBoolRequest { public bool? Value { get; set; } } - public sealed class BareWarningRequest { public int? RoomWarningMask { get; set; } public string? CustomRoomWarning { get; set; } } - // Each per-field route registers BOTH POST and PUT — the 2020.12 // watch's request-builder wraps the HTTP method inside opaque // BPHGKAEDBPE helpers; the existing /name handler at // RoomsController.cs:1455-1456 set the precedent of "register both // because the ISIL is opaque". Same pattern here so the bind never - // 405s. + // 405s. (The 2023 client is unambiguous: verb byte 3 = PUT, e.g. + // IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt:5369.) + // + // …and both bare and roomserver/-prefixed paths, because the 2023 + // in-room roomserver client (NLDBPDCNNCF) issues all of these under the + // roomserver host prefix — a bare-only registration 404s there. + /// PUT rooms/{id}/description — form key + /// description (NLDBPDCNNCF/HBKMCDLDHHO:57). [HttpPost("rooms/{roomId:long}/description")] [HttpPut("rooms/{roomId:long}/description")] - public async Task BareDescription(long roomId, - [FromBody] BareDescriptionRequest? body, - [FromForm(Name = "Description")] string? form) => - await ApplyAndReturn(roomId, r => r.Description = body?.Description ?? form ?? r.Description); + [HttpPost("roomserver/rooms/{roomId:long}/description")] + [HttpPut("roomserver/rooms/{roomId:long}/description")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareDescription(long roomId) + { + // RawValue, not the whitespace-skipping reader: clearing the + // description sends the key with an empty value. + var payload = await ReadPayloadAsync(); + var value = RawValue(payload, "description", "value"); + return await ApplyAndReturn(roomId, r => + { + if (value is not null) r.Description = value; + }); + } + /// PUT rooms/{id}/image — form key imageName + /// (NLDBPDCNNCF/MCDHHIBPJPD:57). [HttpPost("rooms/{roomId:long}/image")] [HttpPut("rooms/{roomId:long}/image")] - public async Task BareImage(long roomId, - [FromBody] BareImageRequest? body, - [FromForm(Name = "ImageName")] string? form) => - await ApplyAndReturn(roomId, r => r.ImageName = body?.ImageName ?? form ?? r.ImageName); + [HttpPost("roomserver/rooms/{roomId:long}/image")] + [HttpPut("roomserver/rooms/{roomId:long}/image")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareImage(long roomId) + { + var payload = await ReadPayloadAsync(); + var value = RawValue(payload, "imageName", "image", "name", "value"); + return await ApplyAndReturn(roomId, r => + { + if (value is not null) r.ImageName = value; + }); + } + /// PUT rooms/{id}/tags. The 2023 client sends the tag + /// lists as REPEATED form keys — autoTag then tag + /// (NLDBPDCNNCF/FOPHALMJKGA:70 and :77, both fed from + /// IReadOnlyList<string> parameters, NLDBPDCNNCF.txt:5543) — + /// NOT a single CSV field, so the old Tags binding read nothing + /// and the edit was a silent no-op. [HttpPost("rooms/{roomId:long}/tags")] [HttpPut("rooms/{roomId:long}/tags")] - public async Task BareTags(long roomId, - [FromBody] BareTagsRequest? body, - [FromForm(Name = "Tags")] string? form) + [HttpPost("roomserver/rooms/{roomId:long}/tags")] + [HttpPut("roomserver/rooms/{roomId:long}/tags")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareTags(long roomId) { - var csv = body?.Tags - ?? (body?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null) - ?? form; - return await ApplyAndReturn(roomId, r => r.TagsCsv = csv ?? r.TagsCsv); + var payload = await ReadPayloadAsync(); + var tags = Values(payload, "autoTag", "tag"); + // 2020.12 watch / admin tooling: one comma-separated "Tags" field. + var csv = Value(payload, "tags"); + if (csv is not null) + tags.AddRange(csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + + // Only rewrite when the request actually carried a tag field — + // otherwise a stray/emptied request would silently wipe the room's + // tags. Sending the keys with no values IS how the client clears + // them, so key presence (not value count) is the signal. + var carriesTags = payload.ContainsKey("autoTag") || payload.ContainsKey("tag") || payload.ContainsKey("tags"); + var joined = string.Join(',', tags.Distinct(StringComparer.OrdinalIgnoreCase)); + return await ApplyAndReturn(roomId, r => + { + if (carriesTags) r.TagsCsv = joined; + }); } [HttpPost("rooms/{roomId:long}/accessibility")] @@ -644,43 +724,129 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase return null; } - private async Task ReadJsonElementAsync(params string[] stringKeys) + // ── Multi-field payload reader ────────────────────────────────────── + // + // The single-key Read*Async helpers above each parse Request.Body, and a + // request body can only be consumed once — calling two of them in the + // same handler silently loses every field after the first. Handlers that + // need several fields (report / modify / restrictions / warning / bans / + // loadscreen / promo_external) read the payload ONCE into the map below. + // + // It is a key → values map because the 2023 client encodes list + // parameters as REPEATED form keys (tags sends tag=…&tag=…, + // bans sends id=…&id=…). Keys are matched case-insensitively, which is + // what lets one lookup serve both the 2023 camelCase names and the + // 2020.12 PascalCase ones. + + private async Task>> ReadPayloadAsync() { + var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + void Add(string key, string? value) + { + if (string.IsNullOrEmpty(key) || value is null) return; + if (!map.TryGetValue(key, out var list)) map[key] = list = new List(); + list.Add(value); + } + if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); - var dict = new Dictionary(StringComparer.Ordinal); foreach (var key in form.Keys) - { - var value = form[key].FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(value)) dict[key] = value; - } - if (dict.Count == 0) return null; - return JsonSerializer.SerializeToElement(dict); + foreach (var value in form[key]) + Add(key, value); } - - if ((Request.ContentLength ?? 0) > 0) + else if ((Request.ContentLength ?? 0) > 0) { try { using var doc = await JsonDocument.ParseAsync(Request.Body); - return doc.RootElement.Clone(); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (prop.Value.ValueKind == JsonValueKind.Array) + foreach (var item in prop.Value.EnumerateArray()) + Add(prop.Name, JsonScalar(item)); + else + Add(prop.Name, JsonScalar(prop.Value)); + } + } } catch (JsonException) { - return null; + // Non-JSON / truncated body — fall through to the query string. } } - foreach (var key in stringKeys) + // Query string is the last-resort source (a few of these fields ride + // on the URL for the 2020.12 watch). Never let it shadow a body field. + foreach (var key in Request.Query.Keys) { - var value = Request.Query[key].FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(value)) return JsonSerializer.SerializeToElement(value); + if (map.ContainsKey(key)) continue; + foreach (var value in Request.Query[key]) + Add(key, value); } + return map; + } + + private static string? JsonScalar(JsonElement element) => element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => element.ToString(), + _ => null, + }; + + /// First NON-EMPTY value for any of . + private static string? Value(Dictionary> map, params string[] keys) + { + foreach (var key in keys) + { + if (!map.TryGetValue(key, out var list)) continue; + foreach (var value in list) + if (!string.IsNullOrWhiteSpace(value)) return value; + } return null; } + /// First value for any of INCLUDING the + /// empty string. Text fields (description, customWarning) are cleared by + /// sending the key with an empty value, so the non-empty filter in + /// would turn "clear" into a no-op. + private static string? RawValue(Dictionary> map, params string[] keys) + { + foreach (var key in keys) + if (map.TryGetValue(key, out var list) && list.Count > 0) + return list[0]; + return null; + } + + /// Every non-empty value across , in key + /// order. Keys that differ only by case are visited once (the map is + /// case-insensitive, so they'd otherwise duplicate the same list). + private static List Values(Dictionary> map, params string[] keys) + { + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var key in keys) + { + if (!seen.Add(key)) continue; + if (!map.TryGetValue(key, out var list)) continue; + result.AddRange(list.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v.Trim())); + } + return result; + } + + private static int? IntValue(Dictionary> map, params string[] keys) + => int.TryParse(Value(map, keys), out var value) ? value : null; + + private static long? LongValue(Dictionary> map, params string[] keys) + => long.TryParse(Value(map, keys), out var value) ? value : null; + + private static bool? BoolValue(Dictionary> map, params string[] keys) + => TryParseBool(Value(map, keys), out var value) ? value : null; + private static bool TryParseBool(string? raw, out bool value) { if (bool.TryParse(raw, out value)) return true; @@ -726,47 +892,91 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase return json.Length <= maxChars ? json : "[]"; } + /// PUT rooms/{id}/cloning — form key + /// cloningAllowed (NLDBPDCNNCF/ECMHAPMBNMA:67). [HttpPost("rooms/{roomId:long}/cloning")] [HttpPut("rooms/{roomId:long}/cloning")] - public async Task BareCloning(long roomId, [FromBody] BareBoolRequest? body, - [FromForm(Name = "CloningAllowed")] bool? form) => - await ApplyAndReturn(roomId, r => + [HttpPost("roomserver/rooms/{roomId:long}/cloning")] + [HttpPut("roomserver/rooms/{roomId:long}/cloning")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareCloning(long roomId) + { + var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value"); + return await ApplyAndReturn(roomId, r => { - var v = body?.Value ?? form; - if (v is bool vv) r.CloningAllowed = vv; + if (value is bool v) r.CloningAllowed = v; }); + } + /// PUT rooms/{id}/automute. The form key is + /// disable, NOT DisableMicAutoMute + /// (NLDBPDCNNCF/MAICLJKDBOB:67, from + /// ChangeRoomMicAutoMute(bool)) — true means "mic auto-mute is + /// disabled", so it maps straight onto DisableMicAutoMute. [HttpPost("rooms/{roomId:long}/automute")] [HttpPut("rooms/{roomId:long}/automute")] - public async Task BareAutomute(long roomId, [FromBody] BareBoolRequest? body, - [FromForm(Name = "DisableMicAutoMute")] bool? form) => - await ApplyAndReturn(roomId, r => + [HttpPost("roomserver/rooms/{roomId:long}/automute")] + [HttpPut("roomserver/rooms/{roomId:long}/automute")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareAutomute(long roomId) + { + var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value"); + return await ApplyAndReturn(roomId, r => { - var v = body?.Value ?? form; - if (v is bool vv) r.DisableMicAutoMute = vv; + if (value is bool v) r.DisableMicAutoMute = v; }); + } - /// rooms/{id}/restrictions — junior / age-related - /// restriction toggle. Body bool maps to AllowsJuniors (true = no - /// restriction, false = juniors restricted). + /// PUT rooms/{id}/restrictions — the "who can play here" + /// panel. The 2023 client sends FOUR booleans in one request: + /// supportsScreens, supportsWalkVR, + /// supportsTeleportVR, supportsJuniors + /// (NLDBPDCNNCF/GNCDFBNCADM:26/:39/:52/:65, matching the four-bool + /// signature at NLDBPDCNNCF.txt:6025). The old single-bool + /// AllowsJuniors binding dropped three of the four settings. [HttpPost("rooms/{roomId:long}/restrictions")] [HttpPut("rooms/{roomId:long}/restrictions")] - public async Task BareRestrictions(long roomId, [FromBody] BareBoolRequest? body, - [FromForm(Name = "AllowsJuniors")] bool? form) => - await ApplyAndReturn(roomId, r => + [HttpPost("roomserver/rooms/{roomId:long}/restrictions")] + [HttpPut("roomserver/rooms/{roomId:long}/restrictions")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareRestrictions(long roomId) + { + var payload = await ReadPayloadAsync(); + var screens = BoolValue(payload, "supportsScreens"); + var walkVR = BoolValue(payload, "supportsWalkVR"); + var teleportVR = BoolValue(payload, "supportsTeleportVR"); + // 2020.12 watch sent a bare AllowsJuniors/Value instead. + var juniors = BoolValue(payload, "supportsJuniors", "allowsJuniors", "value"); + return await ApplyAndReturn(roomId, r => { - var v = body?.Value ?? form; - if (v is bool vv) r.AllowsJuniors = vv; + if (screens is bool s) r.SupportsScreens = s; + if (walkVR is bool w) r.SupportsWalkVR = w; + if (teleportVR is bool t) r.SupportsTeleportVR = t; + if (juniors is bool j) r.AllowsJuniors = j; }); + } + /// PUT rooms/{id}/warning — content warnings. Form keys + /// are warningMask (int) and customWarning (string) + /// (NLDBPDCNNCF/AIDDFKACPLN:80 and :89), not the RoomWarningMask / + /// CustomRoomWarning names the old [FromBody] DTO used. [HttpPost("rooms/{roomId:long}/warning")] [HttpPut("rooms/{roomId:long}/warning")] - public async Task BareWarning(long roomId, [FromBody] BareWarningRequest? body) => - await ApplyAndReturn(roomId, r => + [HttpPost("roomserver/rooms/{roomId:long}/warning")] + [HttpPut("roomserver/rooms/{roomId:long}/warning")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareWarning(long roomId) + { + var payload = await ReadPayloadAsync(); + var mask = IntValue(payload, "warningMask", "RoomWarningMask"); + // RawValue so clearing the custom warning (empty string) sticks. + var custom = RawValue(payload, "customWarning", "CustomRoomWarning"); + return await ApplyAndReturn(roomId, r => { - if (body?.RoomWarningMask is int m) r.RoomWarningMask = m; - if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning; + if (mask is int m) r.RoomWarningMask = m; + if (custom is not null) r.CustomRoomWarning = custom.Length > 512 ? custom[..512] : custom; }); + } [HttpPost("rooms/{roomId:long}/creator")] [HttpPut("rooms/{roomId:long}/creator")] @@ -831,6 +1041,14 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase }); } + /// PUT rooms/{id}/loadscreen — form keys + /// imageName / title / subtitle + /// (NLDBPDCNNCF/AFBGGBEMOGH:83/:93/:103, verb byte 3 = PUT at + /// NLDBPDCNNCF.txt:8519). Builds the exact three-member object the + /// client's load-screen reader wants — ImageName / Title / Subtitle + /// (EOIMIBBJBCB.txt:255/:282/:298) — and APPENDS it. The old code + /// serialised the whole form dict as one blob and REPLACED the list, so + /// a room could never hold more than one load screen. [HttpPost("rooms/{roomId:long}/loadscreen")] [HttpPut("rooms/{roomId:long}/loadscreen")] [HttpPost("roomserver/rooms/{roomId:long}/loadscreen")] @@ -838,16 +1056,66 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BareLoadScreen(long roomId) { - var payload = await ReadJsonElementAsync("loadScreen", "LoadScreen", "value", "Value"); + var payload = await ReadPayloadAsync(); + var imageName = Value(payload, "imageName", "name", "value") ?? string.Empty; + if (string.IsNullOrWhiteSpace(imageName)) return BadRequest(new { error = "missing_image" }); + var title = RawValue(payload, "title") ?? string.Empty; + var subtitle = RawValue(payload, "subtitle") ?? string.Empty; return await ApplyAndReturn(roomId, r => { - if (payload is JsonElement p) - r.LoadScreensJson = p.ValueKind == JsonValueKind.Array - ? p.GetRawText() - : SerializeLimited(new[] { p }); + // Re-setting an existing image replaces that entry rather than + // stacking a duplicate. + var screens = ReadJsonElementList(r.LoadScreensJson) + .Where(s => !LoadScreenMatches(s, imageName)) + .ToList(); + screens.Add(JsonSerializer.SerializeToElement(new + { + ImageName = imageName, + Title = title, + Subtitle = subtitle, + })); + r.LoadScreensJson = SerializeLimited(screens); }); } + /// DELETE rooms/{id}/loadscreen — "remove room loading + /// screen" (NLDBPDCNNCF.txt:8677, verb byte 4 = DELETE at :8678), form + /// key imageName (NLDBPDCNNCF/DMOMLNEJHGE:61). Was not registered + /// at all, so removing a load screen 405'd. + [HttpDelete("rooms/{roomId:long}/loadscreen")] + [HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareLoadScreenRemove(long roomId) + { + var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value") + ?? string.Empty; + if (string.IsNullOrWhiteSpace(imageName)) return BadRequest(new { error = "missing_image" }); + return await ApplyAndReturn(roomId, r => + { + var screens = ReadJsonElementList(r.LoadScreensJson) + .Where(s => !LoadScreenMatches(s, imageName)) + .ToList(); + r.LoadScreensJson = SerializeLimited(screens); + }); + } + + /// A stored load screen matches when its ImageName does — that + /// is the only identity the client sends on remove. + private static bool LoadScreenMatches(JsonElement item, string imageName) + { + if (item.ValueKind == JsonValueKind.String) + return string.Equals(item.GetString(), imageName, StringComparison.OrdinalIgnoreCase); + if (item.ValueKind != JsonValueKind.Object) return false; + foreach (var key in new[] { "ImageName", "imageName", "Name", "name" }) + { + if (item.TryGetProperty(key, out var prop) && + prop.ValueKind == JsonValueKind.String && + string.Equals(prop.GetString(), imageName, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + [HttpPost("rooms/{roomId:long}/promo_images")] [HttpPut("rooms/{roomId:long}/promo_images")] [HttpPost("roomserver/rooms/{roomId:long}/promo_images")] @@ -882,6 +1150,18 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase }); } + /// PUT rooms/{id}/promo_external — "add room promotional + /// content" (verb byte 3 = PUT at NLDBPDCNNCF.txt:8181). Both the add and + /// the remove closures send exactly two form keys: type — a BOXED + /// System.Int32 (NLDBPDCNNCF/OJAKIAHKALI:017 boxes typeof(System.Int32), + /// key at :022) — and reference, a string (:031). + /// + /// The old code stored the raw form dictionary, so Type landed on the + /// wire as the STRING "1"; the client's promo-external reader + /// (EPPPACFECMH.txt:191/:210 registers Type + Reference, and :233 reads + /// Type through the numeric accessor FAHHHNKECAB while :254 reads + /// Reference through the string accessor BKKEIINKNCL) rejects a string + /// there. Build the two members explicitly and typed. [HttpPost("rooms/{roomId:long}/promo_external")] [HttpPut("rooms/{roomId:long}/promo_external")] [HttpPost("roomserver/rooms/{roomId:long}/promo_external")] @@ -889,70 +1169,186 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BarePromoExternalAdd(long roomId) { - var payload = await ReadJsonElementAsync("url", "Url", "value", "Value"); - if (payload is null) return BadRequest(new { error = "missing_promo_external" }); + var payload = await ReadPayloadAsync(); + var reference = Value(payload, "reference", "Reference", "url", "Url", "value", "Value"); + if (string.IsNullOrWhiteSpace(reference)) + return BadRequest(new { error = "missing_promo_external" }); + var type = IntValue(payload, "type", "Type") ?? 0; return await ApplyAndReturn(roomId, r => { - var items = ReadJsonElementList(r.PromoExternalContentJson); - var raw = payload.Value.GetRawText(); - if (!items.Any(i => string.Equals(i.GetRawText(), raw, StringComparison.Ordinal))) - items.Add(payload.Value); + var items = ReadJsonElementList(r.PromoExternalContentJson) + .Where(i => !PromoExternalMatches(i, type, reference)) + .ToList(); + items.Add(JsonSerializer.SerializeToElement(new + { + Type = type, + Reference = reference, + })); r.PromoExternalContentJson = SerializeLimited(items); }); } + /// DELETE rooms/{id}/promo_external — "remove room + /// promotional content" (verb byte 4 = DELETE at NLDBPDCNNCF.txt:8343). + /// Identity is the (type, reference) PAIR + /// (NLDBPDCNNCF/LLFPMJMCLBP:022 and :031), not an id/url — the old + /// id/url/value lookup always missed, so every remove 400'd with + /// missing_promo_external and promo links could not be deleted. [HttpDelete("rooms/{roomId:long}/promo_external")] [HttpDelete("roomserver/rooms/{roomId:long}/promo_external")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BarePromoExternalRemove(long roomId) { - var match = await ReadStringValueAsync("id", "Id", "url", "Url", "value", "Value"); - if (string.IsNullOrWhiteSpace(match)) return BadRequest(new { error = "missing_promo_external" }); + var payload = await ReadPayloadAsync(); + var reference = Value(payload, "reference", "Reference", "id", "Id", "url", "Url", "value", "Value"); + if (string.IsNullOrWhiteSpace(reference)) + return BadRequest(new { error = "missing_promo_external" }); + // type is absent on the 2020.12 watch's remove — treat "no type" as + // "match on reference alone". + var type = IntValue(payload, "type", "Type"); return await ApplyAndReturn(roomId, r => { var items = ReadJsonElementList(r.PromoExternalContentJson) - .Where(i => !PromoExternalMatches(i, match)) + .Where(i => !PromoExternalMatches(i, type, reference)) .ToList(); r.PromoExternalContentJson = SerializeLimited(items); }); } - private static bool PromoExternalMatches(JsonElement item, string match) + /// A stored promo-external entry matches when its Reference + /// does and (when the caller supplied one) its Type agrees. Legacy rows + /// written before the typed shape landed may be bare strings or carry + /// Url/Id instead of Reference, so those spellings are still probed. + /// + private static bool PromoExternalMatches(JsonElement item, int? type, string reference) { - if (string.Equals(item.GetRawText(), match, StringComparison.OrdinalIgnoreCase)) return true; if (item.ValueKind == JsonValueKind.String) - return string.Equals(item.GetString(), match, StringComparison.OrdinalIgnoreCase); + return string.Equals(item.GetString(), reference, StringComparison.OrdinalIgnoreCase); if (item.ValueKind != JsonValueKind.Object) return false; - foreach (var key in new[] { "Id", "id", "Url", "url", "Value", "value" }) + + var referenceMatches = false; + foreach (var key in new[] { "Reference", "reference", "Url", "url", "Id", "id", "Value", "value" }) { if (item.TryGetProperty(key, out var prop) && prop.ValueKind == JsonValueKind.String && - string.Equals(prop.GetString(), match, StringComparison.OrdinalIgnoreCase)) - return true; + string.Equals(prop.GetString(), reference, StringComparison.OrdinalIgnoreCase)) + { + referenceMatches = true; + break; + } } - return false; + if (!referenceMatches) return false; + if (type is not int wanted) return true; + + foreach (var key in new[] { "Type", "type" }) + { + if (!item.TryGetProperty(key, out var prop)) continue; + if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n == wanted; + if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out n)) return n == wanted; + } + // Untyped legacy row — reference alone is enough. + return true; } - /// rooms/{id}/comments + voice_chat_encryption - /// — fields not persisted in the 2020 RoomEntity. Owner-gated - /// acknowledgement so the watch's settings panel doesn't surface - /// a "save failed" error; populated when the columns land. + /// rooms/{id}/comments (form key disable, Boolean — + /// NLDBPDCNNCF/EKLEDLOPLJB:015/:020) and + /// rooms/{id}/voice_chat_encryption (form key + /// encryptVoiceChat, Boolean — NLDBPDCNNCF/NDKDDENPHJP:015/:020). + /// Both are PUT (verb byte 3 at NLDBPDCNNCF.txt:6879 and :7033) and both + /// are issued under the roomserver/ prefix by the 2023 in-room settings + /// panel — the missing twins made them 404 there. + /// + /// The VALUES still aren't persisted: RoomEntity has no + /// DisableRoomComments / EncryptVoiceChat column, so RoomService.cs:1122 + /// and RoomsController.cs:2130 hardcode both to false on the wire. + /// Adding those columns (plus a migration and the two detail builders) + /// is outside this file. Until then this stays an owner-gated + /// acknowledgement that answers with the full details object the call + /// site's reader [0x1884C1948] expects, so the panel doesn't report a + /// save failure on top of the toggle not sticking. [HttpPost("rooms/{roomId:long}/comments")] [HttpPut("rooms/{roomId:long}/comments")] + [HttpPost("roomserver/rooms/{roomId:long}/comments")] + [HttpPut("roomserver/rooms/{roomId:long}/comments")] [HttpPost("rooms/{roomId:long}/voice_chat_encryption")] [HttpPut("rooms/{roomId:long}/voice_chat_encryption")] + [HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")] + [HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task BareAck(long roomId) => - await ApplyAndReturn(roomId, _ => { /* no-op: not modelled yet */ }); + await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ }); - /// rooms/{id}/modify — bare-path equivalent - /// of api/rooms/v2/modify. Reuses by - /// forwarding the body with RoomId pre-populated from the URL. + /// PUT rooms/{id}/modify — the room-settings panel's bulk + /// save (verb byte 3 = PUT at NLDBPDCNNCF.txt:10829, "modify room"), and + /// the 2023 client issues it under the roomserver/ prefix too. + /// + /// The body is x-www-form-urlencoded with ELEVEN camelCase keys, emitted + /// in this order by NLDBPDCNNCF/MMMNLOBFFKO: name (string, :039), + /// description (string, :049), accessibility (Int32, boxed + /// at :059 / key :063), then eight Booleans — supportsScreens + /// (:076), supportsWalkVR (:089), supportsTeleportVR + /// (:102), supportsJuniors (:115), cloningAllowed (:128), + /// disableMicAutoMute (:141), disableRoomComments (:154) + /// and encryptVoiceChat (:167). + /// + /// The old [FromBody] ModifyRoomRequest forwarder 415'd on that + /// form body before the handler ran, was missing four of the fields, and + /// answered {Result, Room} — but the call site attaches the full + /// details reader [0x1884C1948] (:10826), the same one clone uses, so + /// the response must be BuildRoomServerDetails. + /// + /// disableRoomComments / encryptVoiceChat are deliberately NOT read here: + /// RoomEntity has no column for either, so there is nowhere to put them + /// (see above). [HttpPost("rooms/{roomId:long}/modify")] [HttpPut("rooms/{roomId:long}/modify")] - public async Task BareModify(long roomId, [FromBody] ModifyRoomRequest req) + [HttpPost("roomserver/rooms/{roomId:long}/modify")] + [HttpPut("roomserver/rooms/{roomId:long}/modify")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareModify(long roomId) { - req.RoomId = roomId; - return await Modify(req); + var payload = await ReadPayloadAsync(); + var name = Value(payload, "name", "Name"); + // RawValue for description: clearing it sends the key with "". + var description = RawValue(payload, "description", "Description"); + var accessibility = IntValue(payload, "accessibility", "Accessibility"); + var imageName = Value(payload, "imageName", "ImageName"); + var screens = BoolValue(payload, "supportsScreens", "SupportsScreens"); + var walkVR = BoolValue(payload, "supportsWalkVR", "SupportsWalkVR"); + var teleportVR = BoolValue(payload, "supportsTeleportVR", "SupportsTeleportVR"); + var juniors = BoolValue(payload, "supportsJuniors", "AllowsJuniors"); + var cloning = BoolValue(payload, "cloningAllowed", "CloningAllowed"); + var autoMute = BoolValue(payload, "disableMicAutoMute", "DisableMicAutoMute"); + var mobile = BoolValue(payload, "supportsMobile", "SupportsMobile"); + var vrLow = BoolValue(payload, "supportsVRLow", "SupportsVRLow"); + + var room = await RequireOwnedRoomAsync(roomId); + if (room is null) return NotFound(); + + if (!string.IsNullOrWhiteSpace(name)) room.Name = name.Trim(); + if (description is not null) room.Description = description; + if (imageName is not null) room.ImageName = imageName; + if (accessibility is int a) room.Accessibility = Math.Clamp(a, 0, 2); + if (screens is bool s) room.SupportsScreens = s; + if (walkVR is bool w) room.SupportsWalkVR = w; + if (teleportVR is bool t) room.SupportsTeleportVR = t; + if (juniors is bool j) room.AllowsJuniors = j; + if (cloning is bool c) room.CloningAllowed = c; + if (autoMute is bool m) room.DisableMicAutoMute = m; + if (mobile is bool mo) room.SupportsMobile = mo; + if (vrLow is bool vl) room.SupportsVRLow = vl; + room.UpdatedAt = DateTime.UtcNow; + + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + // Likely a unique-name collision. + return Conflict(new { Result = 2, Message = "name taken" }); + } + return Ok(await BuildRoomDetailsAsync(room)); } /// POST rooms/{id}/clone — bare-path alias of @@ -1064,15 +1460,20 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase // badge, and quest-instance logic can boot the player). Match // RoomService.CloneAsync and tag it "community". TagsCsv = "community", - // First-party RRO templates (AG rooms owned by the system - // account — RecCenter and friends) keep their content in baked - // client geometry; any blob on the row is a MakerPen OVERLAY - // save made against the shared template. A clone must start - // from the pristine baked scene with a fresh (empty) blob, not - // inherit that overlay. Scoped to system-owned templates so - // copying a USER's RRO-derived room (IsAGRoom inherited) still - // carries their MakerPen edits along. - CurrentDataBlobName = IsFirstPartyTemplate(source) ? string.Empty : source.CurrentDataBlobName, + // Carry the source's blob NAME here; GiveCloneItsOwnBlobsAsync + // below replaces it with a copy under the clone's own name, so + // nothing ends up shared. + // + // First-party RRO templates (RecCenter and friends) used to be + // emptied here on the reasoning that their content is baked client + // geometry and any blob is just a MakerPen overlay. That left the + // clone with no data at all: the CDN synthesises a ~1.8 KB stub for + // a name it has nothing stored under, the client fetches it through + // GetRoomData ("/room/{blob}") as part of the copy, and rejects it. + // A clone that kept real content joined fine. Since the blob is now + // copied rather than referenced, the original worry — inheriting an + // overlay on a SHARED template — no longer applies. + CurrentDataBlobName = source.CurrentDataBlobName, }; db.Rooms.Add(clone); await db.SaveChangesAsync(); @@ -1097,9 +1498,9 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase OrderIndex = s.OrderIndex, Name = s.Name, RoomSceneLocationId = s.RoomSceneLocationId, - // Fresh blob when cloning a first-party template — see - // CurrentDataBlobName above. - DataBlobName = IsFirstPartyTemplate(source) ? string.Empty : s.DataBlobName, + // See CurrentDataBlobName above: copied under the clone's own + // name by GiveCloneItsOwnBlobsAsync, never shared. + DataBlobName = s.DataBlobName, StudioSubRoomDataSaveId = s.StudioSubRoomDataSaveId, StudioUnityAssetId = s.StudioUnityAssetId, StudioAssetBundleNamesCsv = s.StudioAssetBundleNamesCsv, @@ -1114,6 +1515,8 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase await db.SaveChangesAsync(); } + await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me); + // The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN) // deserializes the response as the FULL room-details object // (FGCPNAACHIK — the same type get-by-id / rename return on the @@ -1125,11 +1528,144 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase // room". return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes)); } - /// A seeded first-party RRO template (RecCenter, the games): - /// AG-flagged AND owned by the system account. User clones inherit the - /// AG flag but are owned by the player, so they don't match. - private static bool IsFirstPartyTemplate(RoomEntity room) - => room.IsAGRoom && room.CreatorPlayerId == PlayerService.SystemAccountId; + /// Give a freshly-cloned room its OWN copy of the source's save + /// data instead of pointing its scenes at the source's blob. + /// + /// The shallow copy this replaces relied on copy-on-write: the clone + /// referenced room_<source>_*.dat until someone saved over it. + /// That left every clone of a room sharing one object, so a clone read as + /// "already has content" while owning none of it, and the room a player + /// copied stayed load-bearing for their copy forever. Cloning RecCenter + /// produced rooms whose data blob was literally room_100_v1.dat. + /// + /// Each sub-room's bytes are copied to a name derived from the CLONE's id, + /// so files them under that room's own prefix. + /// Sub-room 0 takes the canonical default name, which matters because the + /// details builder falls back to exactly that name when a room has no + /// recorded blob — writing the copy there means both paths resolve to real + /// content. + /// + /// A missing source object is not fatal: the clone keeps an empty blob name + /// and behaves like a fresh room, which is the same outcome as cloning a + /// room that never had a save. Failing the whole clone would be worse — the + /// room and its sub-rooms are already committed by this point. + private async Task GiveCloneItsOwnBlobsAsync( + RoomEntity clone, + List sourceScenes, + List clonedScenes, + long callerId) + { + // With no object store there are no objects to copy, and a blob name is + // only a reference. Clearing it would throw away the player's edits — + // strictly worse than the shared reference — so leave the shallow copy + // alone and let copy-on-write handle it, exactly as before. + if (!storage.IsS3Configured) + { + logger.LogInformation( + "[room-clone] no object store configured; room {Room} keeps the source's blob references", + clone.Id); + return; + } + + string? subRoomZeroBlob = null; + + for (var i = 0; i < clonedScenes.Count; i++) + { + var target = clonedScenes[i]; + // The scene was created as a shallow copy, so its DataBlobName is + // currently the SOURCE's. Either a copy replaces it or it gets + // cleared — it must never survive as-is. + var copied = await CopyBlobForCloneAsync( + sourceScenes[i].DataBlobName, clone.Id, target.OrderIndex, callerId); + + target.DataBlobName = copied ?? string.Empty; + target.DataModifiedAt = DateTime.UtcNow; + if (target.OrderIndex == 0) subRoomZeroBlob = copied; + } + + // Same rule for the room row, which carries its own inherited copy of + // the name. Sub-room 0 is the room's current save, so prefer whatever + // that produced; with no sub-room 0 at all, copy the room-level blob + // directly. Anything that can't be copied is cleared rather than left + // pointing into another room. + if (clonedScenes.Any(s => s.OrderIndex == 0)) + { + clone.CurrentDataBlobName = subRoomZeroBlob ?? string.Empty; + } + else + { + clone.CurrentDataBlobName = + await CopyBlobForCloneAsync(clone.CurrentDataBlobName, clone.Id, 0, callerId) + ?? string.Empty; + } + + await db.SaveChangesAsync(); + } + + /// Copy one blob into the clone's own namespace. Returns the new + /// name, or null when there was nothing to copy or the source object could + /// not be read — in which case the caller clears the reference. + /// + /// A missing source is not fatal: the clone then behaves like a room that + /// was never saved, which is also what cloning an unsaved room does. The + /// room and its sub-rooms are already committed by this point, so failing + /// the request outright would leave a half-made room behind. + private async Task CopyBlobForCloneAsync( + string? sourceBlob, long cloneId, int orderIndex, long callerId) + { + // A first-party template deliberately starts empty (see + // CurrentDataBlobName in BareClone), and a sub-room may simply never + // have been saved. + if (string.IsNullOrWhiteSpace(sourceBlob)) return null; + + byte[]? bytes; + try + { + var (srcBucket, srcKey) = BlobRouter.Route(sourceBlob); + bytes = await storage.GetAsync(srcBucket, srcKey); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "[room-clone] could not read source blob {Blob}; room {Room} sub-room {Sub} starts empty", + sourceBlob, cloneId, orderIndex); + return null; + } + + if (bytes is null || bytes.Length == 0) + { + logger.LogWarning( + "[room-clone] source blob {Blob} is missing from storage; room {Room} sub-room {Sub} " + + "starts empty", sourceBlob, cloneId, orderIndex); + return null; + } + + var newBlob = CloneBlobName(cloneId, orderIndex); + var (dstBucket, dstKey) = BlobRouter.Route(newBlob); + await storage.PutAsync(dstBucket, dstKey, bytes, "application/octet-stream"); + + db.RoomDataBlobs.Add(new RoomDataBlobEntity + { + RoomId = cloneId, + BlobName = newBlob, + SubRoomId = orderIndex, + UploadedByPlayerId = callerId, + UploadedAt = DateTime.UtcNow, + }); + + logger.LogInformation( + "[room-clone] copied {Bytes} bytes {From} → {To} (room {Room} sub-room {Sub})", + bytes.Length, sourceBlob, newBlob, cloneId, orderIndex); + return newBlob; + } + + /// Blob name for a cloned sub-room, keyed by the CLONE's room id so + /// BlobRouter files it under that room rather than the source's. + private static string CloneBlobName(long roomId, int orderIndex) => + orderIndex == 0 + ? RoomService.SyntheticDefaultRoomDataBlobName(roomId) + : $"room_{roomId}_dorknet_v8_sub{orderIndex}.dat"; + private async Task ReadCloneNameAsync() { @@ -1161,24 +1697,89 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase } // ── Bare-path bans aliases ───────────────────────────────────────── - - public sealed class BareBanRequest { public long PlayerId { get; set; } public int BanType { get; set; } public DateTime? Until { get; set; } public string? Reason { get; set; } } + // + // Every one of these needs the roomserver/ twin: the 2023 in-room + // banned-players panel issues them under that prefix + // (NLDBPDCNNCF.txt:12901 GET, :13044 POST "ban players", :13191 POST + // "rooms/{0}/bans/import", :13353 DELETE "rooms/{0}/bans/{1}"), and a + // bare-only registration 404s there. [HttpGet("rooms/{roomId:long}/bans")] + [HttpGet("roomserver/rooms/{roomId:long}/bans")] public Task BareBansList(long roomId) => RoomBansList(roomId); + /// POST rooms/{id}/bans — "ban players" (verb byte 2 = + /// POST at NLDBPDCNNCF.txt:13060). The client sends + /// x-www-form-urlencoded with ONE banMask (boxed Int32) plus a + /// REPEATED id key — one per account being banned + /// (NLDBPDCNNCF/DMMILEJMMCC:024 and :034; the `id` binder is the + /// list-valued overload, fed from the + /// IReadOnlyList<Int32> parameter of the issuing method at + /// NLDBPDCNNCF.txt:12920). The old [FromBody] BareBanRequest + /// rejected the form body with 415 before the handler ran, and named + /// PlayerId/BanType — keys the client never sends — so in-room bans + /// never persisted. No response reader is attached at the call site + /// (plain BDBHCEGKGDC dispatch, :13065), so the ack shape is free. + /// [HttpPost("rooms/{roomId:long}/bans")] - public Task BareBan(long roomId, [FromBody] BareBanRequest req) => - BanFromRoom(new BanFromRoomRequest + [HttpPost("roomserver/rooms/{roomId:long}/bans")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareBan(long roomId) + { + var room = await RequireOwnedRoomAsync(roomId); + if (room is null) return NotFound(); + + var payload = await ReadPayloadAsync(); + // banMask IS the BanType enum on the wire. Default 1 (Permanent): + // the in-room panel's only ban action is a permanent ban. + var banType = IntValue(payload, "banMask", "banType", "type", "BanType") ?? 1; + var reason = RawValue(payload, "reason", "Reason") ?? string.Empty; + DateTime? until = DateTime.TryParse(Value(payload, "until", "Until"), out var parsedUntil) + ? (DateTime?)parsedUntil.ToUniversalTime() + : null; + + // "id" is the 2023 key; the others are 2020.12 / admin spellings. The + // payload map is case-insensitive, so PascalCase variants fall out. + var ids = Values(payload, "id", "playerId", "accountId") + .Select(v => long.TryParse(v, out var n) ? n : 0L) + .Where(n => n > 0) + .Distinct() + .ToList(); + if (ids.Count == 0) return BadRequest(new { error = "missing_player" }); + + var existing = await db.RoomBans + .Where(b => b.RoomId == roomId && ids.Contains(b.BannedPlayerId)) + .ToListAsync(); + foreach (var id in ids) { - RoomId = roomId, - PlayerId = req.PlayerId, - BanType = req.BanType, - Until = req.Until, - Reason = req.Reason, - }); + // Re-banning an already-banned player updates the row rather than + // stacking duplicates — the panel re-sends the whole selection. + var row = existing.FirstOrDefault(b => b.BannedPlayerId == id); + if (row is null) + { + db.RoomBans.Add(new RoomBanEntity + { + RoomId = roomId, + BannedPlayerId = id, + BannedByPlayerId = Me, + BanType = banType, + Until = until, + Reason = reason.Length > 512 ? reason[..512] : reason, + }); + } + else + { + row.BanType = banType; + row.Until = until; + if (reason.Length > 0) row.Reason = reason.Length > 512 ? reason[..512] : reason; + } + } + await db.SaveChangesAsync(); + return Ok(new { Result = 0, Banned = ids.Count }); + } [HttpDelete("rooms/{roomId:long}/bans/{playerId:long}")] + [HttpDelete("roomserver/rooms/{roomId:long}/bans/{playerId:long}")] public async Task BareUnban(long roomId, long playerId) { var room = await RequireOwnedRoomAsync(roomId); @@ -1192,11 +1793,59 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase return Ok(new { Removed = rows.Count }); } + /// POST rooms/{id}/bans/import — "import ban list" + /// (verb byte 2 = POST at NLDBPDCNNCF.txt:13207). The client names a + /// SOURCE ROOM whose bans should be copied in — a single Int64 form key + /// sourceRoomId (NLDBPDCNNCF/KGDNOLMFENE:015 boxes System.Int64, + /// :020 is the key) — NOT an explicit ban list. The old + /// [FromBody] ImportRoomBansRequest therefore 415'd on the form + /// body AND modelled the wrong contract. The JSON ban-list shape stays + /// available on the legacy api/rooms/v1/importroombans. [HttpPost("rooms/{roomId:long}/bans/import")] - public Task BareBansImport(long roomId, [FromBody] ImportRoomBansRequest req) + [HttpPost("roomserver/rooms/{roomId:long}/bans/import")] + [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] + public async Task BareBansImport(long roomId) { - req.RoomId = roomId; - return ImportRoomBans(req); + var target = await RequireOwnedRoomAsync(roomId); + if (target is null) return NotFound(); + + var payload = await ReadPayloadAsync(); + // Deliberately NOT aliased to "roomId" — that name means the TARGET + // room elsewhere in this surface and would silently self-import. + var sourceRoomId = LongValue(payload, "sourceRoomId", "SourceRoomId") ?? 0; + if (sourceRoomId <= 0) return BadRequest(new { error = "missing_source_room" }); + if (sourceRoomId == roomId) return Ok(new { Imported = 0 }); + + // Owner-gate BOTH rooms — importing bulk-reads the source room's ban + // list, which is owner-only information (see RoomBansList). + var source = await RequireOwnedRoomAsync(sourceRoomId); + if (source is null) return Forbid(); + + var sourceBans = await db.RoomBans + .Where(b => b.RoomId == sourceRoomId) + .ToListAsync(); + var seen = new HashSet(await db.RoomBans + .Where(b => b.RoomId == roomId) + .Select(b => b.BannedPlayerId) + .ToListAsync()); + + var imported = 0; + foreach (var b in sourceBans) + { + if (!seen.Add(b.BannedPlayerId)) continue; + db.RoomBans.Add(new RoomBanEntity + { + RoomId = roomId, + BannedPlayerId = b.BannedPlayerId, + BannedByPlayerId = Me, + BanType = b.BanType, + Until = b.Until, + Reason = b.Reason, + }); + imported++; + } + if (imported > 0) await db.SaveChangesAsync(); + return Ok(new { Imported = imported }); } private async Task ApplyAndReturn(long roomId, Action mutator) @@ -1207,6 +1856,33 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase room.UpdatedAt = DateTime.UtcNow; try { await db.SaveChangesAsync(); } catch (DbUpdateException) { return Conflict(new { Result = 2 }); } - return Ok(Services.RoomService.ToWireRoom(room)); + return Ok(await BuildRoomDetailsAsync(room)); + } + + /// The response body EVERY bare-path room mutation must return. + /// + /// The 2023 roomserver client dispatches all of them through the same + /// typed helper (0x1835E50A0) with the same response-reader token + /// [0x1884C1948] — see NLDBPDCNNCF.txt:5366 (description), + /// :5691 (tags), :6006 (accessibility), :6169 (restrictions), + /// :6335 (cloning), :6710 (automute), :6876 (comments), :7195 (min_level), + /// :7360 (allow_new_users), :7859 (promo_images), :8178 (promo_external), + /// :8516 (loadscreen), :10826 (modify). That is the SAME token the clone + /// call site uses (:4815), which the repo already established is the full + /// FGCPNAACHIK details object built by BuildRoomServerDetails (see + /// ). The slim RoomService.ToWireRoom has no + /// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict + /// reader's dispose-walk NRE'd on the missing nested lists and every + /// mutation reported failure even though it had persisted. + private async Task BuildRoomDetailsAsync(RoomEntity room) + { + var scenes = await db.RoomScenes + .Where(s => s.RoomId == room.Id) + .OrderBy(s => s.OrderIndex) + .ToListAsync(); + var roles = await db.RoomRoles + .Where(r => r.RoomId == room.Id) + .ToListAsync(); + return RoomsController.BuildRoomServerDetails(room, scenes, roles: roles); } } diff --git a/DorkNet.Server/Controllers/API/ScreenShareReports/ScreenShareReportsController.cs b/DorkNet.Server/Controllers/API/ScreenShareReports/ScreenShareReportsController.cs index 2e898b9..15ddc95 100644 --- a/DorkNet.Server/Controllers/API/ScreenShareReports/ScreenShareReportsController.cs +++ b/DorkNet.Server/Controllers/API/ScreenShareReports/ScreenShareReportsController.cs @@ -10,22 +10,50 @@ namespace DorkNet.Server.Controllers.API.ScreenShareReports; [Authorize] public class ScreenShareReportsController(DorkNetDbContext db) : ControllerBase { + /// Screen-share (photo) report. The 2023-03-21 client posts the + /// form keys ImageName, ReportedPlayerId, RoomId, + /// RoomInstanceId, RoomInstanceType and Details + /// (RecNet.Runtime/KAEAEIODGBG.txt:281-360). It sends no + /// reportCategory at all. + /// + /// The old signature bound targetPlayerId, so the reported player + /// was stored as 0 on every screen-share report and the reported image was + /// dropped entirely — the report was unactionable. Form binding is + /// case-insensitive, so RoomId/Details did land; the names + /// that differ are the ones spelled out explicitly below. [HttpPost("api/screensharereports/v1/report")] public async Task Report( + [FromForm(Name = "ReportedPlayerId")] long? reportedPlayerId, [FromForm] long? targetPlayerId, [FromForm] long? roomId, + [FromForm(Name = "RoomInstanceId")] long? roomInstanceId, + [FromForm(Name = "RoomInstanceType")] int? roomInstanceType, + [FromForm(Name = "ImageName")] string? imageName, [FromForm] int? reportCategory, [FromForm] string? details) { var reporter = this.RequireCurrentPlayerId(); - var target = targetPlayerId - ?? (long.TryParse(Request.Query["targetPlayerId"].FirstOrDefault(), out var qTarget) ? qTarget : 0); + var target = reportedPlayerId + ?? targetPlayerId + ?? (long.TryParse(Request.Query["ReportedPlayerId"].FirstOrDefault() + ?? Request.Query["targetPlayerId"].FirstOrDefault(), out var qTarget) ? qTarget : 0); var room = roomId ?? (long.TryParse(Request.Query["roomId"].FirstOrDefault(), out var qRoom) ? qRoom : 0); + // The client never sends a category for screen-share reports; 5 is the + // server's existing "other" bucket. var category = reportCategory ?? (int.TryParse(Request.Query["reportCategory"].FirstOrDefault(), out var qCategory) ? qCategory : 5); var message = details ?? Request.Query["details"].FirstOrDefault() ?? string.Empty; + // Keep the evidence (image + instance) in the stored text: ReportEntity + // has no dedicated columns for them and a report without the offending + // image is not actionable by a moderator. + var context = new List(); + if (!string.IsNullOrWhiteSpace(imageName)) context.Add($"image={imageName}"); + if (roomInstanceId is > 0) context.Add($"instance={roomInstanceId}"); + if (roomInstanceType is not null) context.Add($"instanceType={roomInstanceType}"); + if (context.Count > 0) message = $"[screenshare {string.Join(' ', context)}] {message}".Trim(); + db.Reports.Add(new ReportEntity { ReporterPlayerId = reporter, diff --git a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs index 61d0f41..01196e4 100644 --- a/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs +++ b/DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs @@ -40,6 +40,16 @@ public class StorefrontsBuyController( public int StorefrontType { get; set; } public int PurchasableItemId { get; set; } public int CurrencyType { get; set; } = 2; + + /// The price the tile displayed, sent as a stale-price guard. + /// The field was missing here, so buyTier/buyElite had nothing to + /// validate against and fell back to a flat cost. Verified on the + /// 2023-03-21 build: Nullable<Int64> at +32 in + /// RecNet.Runtime/DCFKEFHJAGC_NestedType_RequestPurchaseItemDTO.txt:123-137, + /// wire key "RequestedPrice" in the generated JSON contract + /// RecNet.Runtime/FPLGJGDJIJG.txt:534. + public long? RequestedPrice { get; set; } + public long? CouponConsumablePlayerMappingId { get; set; } public GiftDto? Gift { get; set; } } @@ -185,36 +195,75 @@ public class StorefrontsBuyController( isGift ? null : gift.Id)); } + /// POST api/storefronts/v2/buyTier — season-pass "skip a + /// tier" button. The tier is an ordinary StoreItems row on the + /// season:{n} storefront (see StorefrontsController.Season), so its + /// real price is charged rather than a flat 1 token, and the purchase is + /// recorded as a seasonTier:* progress row so the season page can + /// count how many tiers the player bought. [HttpPost("api/storefronts/v2/buyTier")] public async Task BuyTier([FromBody] RequestPurchaseItemDto req) { - // Season-pass tier: no per-item table, so we just deduct a - // small fixed cost (1 token) per tier and ack. A full - // season-pass system would have a SeasonProgressEntity. + if (req is null) return BadRequest("missing body"); var pid = Me; - const int TierCost = (int)StoreService.StoreItemPrice; + var item = await db.StoreItems + .FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive); + // Tiers that aren't seeded as StoreItems still cost the catalogue + // default so the button isn't free on a bare install. + var cost = Math.Max(0, item?.Price ?? StoreService.StoreItemPrice); + var balance = await level.GetBalanceAsync(pid, req.CurrencyType); - if (balance < TierCost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType)); - var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -TierCost, - $"buyTier:{req.PurchasableItemId}"); + // Stale-price guard: the client sends the price its tile showed. + if (req.RequestedPrice is long requested && requested != cost) + return Ok(BalanceUpdateResponse(balance, req.CurrencyType)); + if (balance < cost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType)); + + var newBalance = cost == 0 + ? balance + : await level.GrantCurrencyAsync(pid, req.CurrencyType, -cost, + $"buyTier:{req.PurchasableItemId}"); + await RecordSeasonProgressAsync(pid, + $"seasonTier:{req.StorefrontType}:{req.PurchasableItemId}"); return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType)); } + /// POST api/storefronts/v2/buyElite — season "elite + /// upgrade" (the paid premium track). There is no real-money flow on a + /// private server, so it is priced in tokens off the same StoreItems row + /// the season storefront advertised. The flag is persisted as a + /// seasonElite:* progress row. [HttpPost("api/storefronts/v2/buyElite")] public async Task BuyElite([FromBody] RequestPurchaseItemDto req) { - // Elite-tier purchase (paid premium track). Without real - // money flow on a private server we just ack. + if (req is null) return BadRequest("missing body"); var pid = Me; + var eliteKey = $"seasonElite:{req.StorefrontType}"; var balance = await level.GetBalanceAsync(pid, req.CurrencyType); - return Ok(BalanceUpdateResponse(balance, req.CurrencyType)); + + // Already upgraded — ack without charging again so a replayed tap + // can't drain the wallet. + if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == eliteKey)) + return Ok(BalanceUpdateResponse(balance, req.CurrencyType)); + + var item = await db.StoreItems + .FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive); + var cost = Math.Max(0, item?.Price ?? StoreService.StoreItemPrice); + if (req.RequestedPrice is long requested && requested != cost) + return Ok(BalanceUpdateResponse(balance, req.CurrencyType)); + if (balance < cost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType)); + + var newBalance = cost == 0 + ? balance + : await level.GrantCurrencyAsync(pid, req.CurrencyType, -cost, + $"buyElite:{req.PurchasableItemId}"); + await RecordSeasonProgressAsync(pid, eliteKey); + return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType)); } /// GET api/storefronts/v1/buyRoomKey — purchase a /// persisted room key. The 2020.12 client sends roomKeyId and the /// price it displayed so we reject stale-price purchases. [HttpGet("api/storefronts/v1/buyRoomKey")] - [HttpGet("api/storefronts/v1/PurchaseRoomKeyWithCurrency")] public async Task BuyRoomKey( [FromQuery] long roomKeyId, [FromQuery] int requestedPrice) @@ -252,93 +301,369 @@ public class StorefrontsBuyController( return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance)); } + public sealed class PurchaseRoomKeyWithCurrencyRequest + { + public long RoomKeyId { get; set; } + public long RequestedPrice { get; set; } + public Guid RequestedPurchaseCurrencyId { get; set; } + } + + /// POST api/storefronts/v1/PurchaseRoomKeyWithCurrency — + /// buying a room key with a ROOM currency instead of tokens. + /// + /// This was only registered as a [HttpGet] alias of BuyRoomKey, so + /// every attempt 405'd. The 2023-03-21 client issues it with verb 2 (POST — + /// RecNet.Runtime/DCFKEFHJAGC.txt:7426) and three form fields, + /// RoomKeyId (Int64, :7439), RequestedPrice (Int64, :7451) and + /// RequestedPurchaseCurrencyId (Guid, :7464); the DTO signature is + /// INBGDNMFJBP(Int64, Int64, Guid) at :7206. + /// + /// The response is RoomKeyPurchaseWithCurrencyResponseDTO, whose + /// generated contract has exactly two members — Balance and + /// RoomKeyResponse (RecNet.Runtime/EDMKBBPGJAB.txt:203/222) — + /// where Balance is the ROOM-currency balance row + /// {AccountId, CurrencyId, Balance, ModifiedAt} + /// (RecNet.Runtime/DPNLANKOHON.txt:331-414), NOT the token + /// BalanceUpdateResponse that the GET alias returned. + [HttpPost("api/storefronts/v1/PurchaseRoomKeyWithCurrency")] + public async Task PurchaseRoomKeyWithCurrency( + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] + PurchaseRoomKeyWithCurrencyRequest req) + { + var pid = Me; + var currency = await db.RoomCurrencies.FirstOrDefaultAsync( + c => c.PublicId == req.RequestedPurchaseCurrencyId && !c.IsDeleted); + var key = await db.RoomKeys.FirstOrDefaultAsync( + k => k.Id == req.RoomKeyId && !k.IsDeleted); + + RoomCurrencyBalanceEntity? row = null; + if (currency is not null) + { + var currencyRowId = currency.Id; + row = await db.RoomCurrencyBalances.FirstOrDefaultAsync( + b => b.PlayerId == pid && b.RoomCurrencyId == currencyRowId); + } + + if (currency is null) + return Ok(RoomKeyCurrencyPurchaseResponse( + RoomKeyStatus.InvalidParameters, key, pid, req.RequestedPurchaseCurrencyId, null)); + if (key is null) + return Ok(RoomKeyCurrencyPurchaseResponse( + RoomKeyStatus.DoesNotExist, null, pid, currency.PublicId, row)); + if (key.CreatorPlayerId == pid) + return Ok(RoomKeyCurrencyPurchaseResponse( + RoomKeyStatus.PlayerAlreadyOwns, key, pid, currency.PublicId, row)); + var roomKeyRowId = key.Id; + if (await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyRowId && p.PlayerId == pid)) + return Ok(RoomKeyCurrencyPurchaseResponse( + RoomKeyStatus.PlayerAlreadyOwns, key, pid, currency.PublicId, row)); + if (req.RequestedPrice != key.Price) + return Ok(RoomKeyCurrencyPurchaseResponse( + RoomKeyStatus.InvalidParameters, key, pid, currency.PublicId, row)); + + var balance = row?.Balance ?? 0; + if (balance < key.Price) + return Ok(RoomKeyCurrencyPurchaseResponse( + RoomKeyStatus.PurchaseFailed, key, pid, currency.PublicId, row)); + + if (row is null) + { + row = new RoomCurrencyBalanceEntity + { + PlayerId = pid, + RoomCurrencyId = currency.Id, + Balance = 0, + }; + db.RoomCurrencyBalances.Add(row); + } + row.Balance = Math.Max(0, row.Balance - key.Price); + row.UpdatedAt = DateTime.UtcNow; + db.RoomKeyPurchases.Add(new RoomKeyPurchaseEntity + { + RoomKeyId = key.Id, + PlayerId = pid, + PaidPrice = key.Price, + }); + await db.SaveChangesAsync(); + + return Ok(RoomKeyCurrencyPurchaseResponse( + RoomKeyStatus.Success, key, pid, currency.PublicId, row)); + } + + /// GET api/storefronts/v2/buyInvention — MakerPen store + /// purchase of a paid invention. Verb is 0/GET + /// (RecNet.Runtime/DCFKEFHJAGC.txt:6772) with query fields + /// inventionId (Int64, :6785) and requestedPrice (Int32, + /// :6797) — the latter was being ignored. + /// + /// The response type is InventionPurchaseResponseDTO, whose contract + /// is {InventionResponse, BalanceUpdateResponse} + /// (RecNet.Runtime/MAHHAAEGEEP.txt:215/242). The handler used to + /// return the bare token-balance wrapper, so InventionResponse was + /// absent and the client got Status/Invention/InventionVersion = null — + /// it could never confirm the purchase or spawn what it just bought. + /// InventionResponse itself is {Status, Invention, InventionVersion} + /// (RecNet.Runtime/GMBGBPNMGBA.txt:255/274/290) — the same triple + /// InventionsController already returns from addversion. + [HttpGet("api/storefronts/v2/buyInvention")] [HttpPost("api/storefronts/v2/buyInvention")] - [HttpGet("api/storefronts/v2/buyInvention")] - public async Task BuyInvention([FromQuery] long inventionId, [FromQuery] int currencyType = 2) + public async Task BuyInvention( + [FromQuery] long inventionId, + [FromQuery] long? requestedPrice = null, + [FromQuery] int currencyType = 2) { var pid = Me; - var inv = await db.Inventions.FirstOrDefaultAsync(i => i.Id == inventionId && !i.IsDeleted && i.IsPublished); - if (inv is null) return NotFound(); - var price = Math.Max(0, inv.Price); var balance = await level.GetBalanceAsync(pid, currencyType); - if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType)); + var inv = await db.Inventions.FirstOrDefaultAsync( + i => i.Id == inventionId && !i.IsDeleted && i.IsPublished); + if (inv is null) + return Ok(InventionPurchaseResponse( + InventionStatusDoesNotExist, null, null, balance, currencyType)); + + var version = await db.InventionVersions + .Where(v => v.InventionId == inv.Id) + .OrderByDescending(v => v.VersionNumber) + .FirstOrDefaultAsync(); + var price = Math.Max(0, inv.Price); + + // Stale-price guard — the MakerPen tile sends the price it rendered. + if (requestedPrice is long asked && asked != price) + return Ok(InventionPurchaseResponse( + InventionStatusInvalidParameters, inv, version, balance, currencyType)); + + var key = $"invention_purchase:{inv.Id}"; + var alreadyOwns = inv.CreatorPlayerId == pid + || await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key); + if (alreadyOwns) + { + // Deliberately Success rather than PlayerAlreadyOwns (=19): the + // MakerPen re-issues this call on every spawn of a paid creation, + // and a non-zero status blocks the spawn. + return Ok(InventionPurchaseResponse( + InventionStatusSuccess, inv, version, balance, currencyType)); + } + + if (balance < price) + return Ok(InventionPurchaseResponse( + InventionStatusPurchaseFailed, inv, version, balance, currencyType)); + var newBalance = price == 0 ? balance : await level.GrantCurrencyAsync(pid, currencyType, -price, $"buyInvention:{inv.Id}"); - var key = $"invention_purchase:{inv.Id}"; - if (!await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key)) + db.ObjectiveProgress.Add(new ObjectiveProgressEntity + { + PlayerId = pid, + Key = key, + IsCompleted = true, + ClearedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + + if (price > 0 && inv.CreatorPlayerId != pid) + await level.GrantCurrencyAsync(inv.CreatorPlayerId, currencyType, price, + $"sellInvention:{inv.Id}"); + + return Ok(InventionPurchaseResponse( + InventionStatusSuccess, inv, version, newBalance, currencyType)); + } + + /// Body of buyForFreeGiftButton. Unlike its siblings this + /// route does NOT use form fields: the client serialises a + /// RequestPurchaseForFreeGiftButtonDTO and posts it as the whole + /// body (RecNet.Runtime/DCFKEFHJAGC.txt:5910-5924, verb 2 at :5912). + /// Field offsets +16/+24/+40/+44/+48 in + /// DCFKEFHJAGC_NestedType_RequestPurchaseForFreeGiftButtonDTO.txt, + /// wire keys PurchasableItemId / CouponConsumablePlayerMappingId / Count / + /// CreatorPlayerId / RequestedPrice in RecNet.Runtime/IAPMDDFENDD.txt:395-502. + /// + public sealed class FreeGiftButtonRequest + { + public int PurchasableItemId { get; set; } + public long? CouponConsumablePlayerMappingId { get; set; } + public int Count { get; set; } = 1; + public int CreatorPlayerId { get; set; } + public long? RequestedPrice { get; set; } + } + + /// POST api/storefronts/v1/buyForFreeGiftButton — the + /// in-room "free gift button" gadget handing an item to whoever pressed it. + /// + /// The handler bound [FromQuery] currencyType/price while the client + /// sends everything in the JSON body, so the requested item id never + /// arrived and a hardcoded 25-token "FreeGiftButton" package was inserted + /// instead — the gadget paid out the wrong reward on every press. Now the + /// body is read and the requested StoreItems row is granted directly. + /// + /// The response is the plain BalanceResponseDTO + /// (DCFKEFHJAGC.txt:6008/6055) whose contract is exactly + /// {Balance, CurrencyType, Platform} — RecNet.Runtime/PECGEJAAMHB.txt:255/274/298. + /// There is no gift-package member, so nothing can be handed back through a + /// gift box here; the item has to land in the inventory server-side. + [HttpPost("api/storefronts/v1/buyForFreeGiftButton")] + [HttpGet("api/storefronts/v1/buyForFreeGiftButton")] + public async Task BuyForFreeGiftButton( + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] + FreeGiftButtonRequest req, + [FromQuery] int currencyType = 2) + { + var pid = Me; + var balance = await level.GetBalanceAsync(pid, currencyType); + var item = await db.StoreItems + .FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive); + if (item is null) return Ok(BalanceResponse(balance, currencyType)); + + // RequestedPrice is what the gadget quoted, and it is the total for the + // press — the gadget is normally free, so the item's catalogue price is + // deliberately NOT charged here. CouponConsumablePlayerMappingId is + // carried by the DTO but there is no coupon-mapping table to redeem it + // against yet, so it is accepted and ignored. + var price = Math.Max(0, req.RequestedPrice ?? 0); + if (balance < price) return Ok(BalanceResponse(balance, currencyType)); + var newBalance = price == 0 + ? balance + : await level.GrantCurrencyAsync(pid, currencyType, -price, + $"freeGiftButton:{item.Slug}"); + + await GrantPurchasedItemAsync(pid, item, Math.Clamp(req.Count, 1, 50)); + return Ok(BalanceResponse(newBalance, currencyType)); + } + + /// Form body of buyProgressionEventXpBoost, verified at + /// RecNet.Runtime/DCFKEFHJAGC.txt:12431 (progressionEventId, Int64), + /// :12444 (purchasableXpBoostId, Guid), :12456 (requestedPrice, Int32) and + /// :12468 (expectedXp, Int32). + public sealed class ProgressionEventXpBoostRequest + { + public long ProgressionEventId { get; set; } + public Guid PurchasableXpBoostId { get; set; } + public int RequestedPrice { get; set; } + public int ExpectedXp { get; set; } + } + + /// POST api/storefronts/v1/buyProgressionEventXpBoost. + /// + /// Two defects: the handler bound [FromQuery] currencyType/price so + /// none of the four form fields arrived (nothing charged, no XP granted), + /// and it reused the shared BalanceUpdateResponse whose + /// BalanceUpdates[].Data is a LIST. This route's response is + /// BalanceUpdateResponseDTO`1<PurchasedProgressionEventXpBoostDTO> + /// (signature at DCFKEFHJAGC.txt:12118) and that family's inner Data + /// is a SINGLE object — get_Data returns DataTypeDTO, not + /// List<DataTypeDTO>, in + /// DCFKEFHJAGC_NestedType_BalanceUpdateResponseDTO`1_NestedType_DEJIAFEKFCK.txt:83 + /// (contrast the Purchase* variant at + /// ..._PurchaseBalanceUpdateResponseDTO`1_NestedType_PKKNEGNNABE.txt:83, + /// which IS a List). An array there makes the reader throw. + /// The payload object is {Xp:Int32} — RecNet.Runtime/NNEJHCKLOHL.txt:139. + [HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")] + [HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")] + public async Task BuyProgressionEventXpBoost( + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] + ProgressionEventXpBoostRequest req, + [FromQuery] int currencyType = 2) + { + var pid = Me; + var price = Math.Max(0, req.RequestedPrice); + var balance = await level.GetBalanceAsync(pid, currencyType); + if (balance < price) return Ok(XpBoostResponse(balance, currencyType, 0)); + + var newBalance = price == 0 + ? balance + : await level.GrantCurrencyAsync(pid, currencyType, -price, + $"progressionEventXpBoost:{req.ProgressionEventId}"); + + // The client tells us how much XP the boost it just paid for is worth; + // clamped so a spoofed field can't mint arbitrary levels. + var xp = Math.Clamp(req.ExpectedXp, 0, 100_000); + if (xp > 0) + await level.AwardXpAsync(pid, xp, $"progressionEventXpBoost:{req.ProgressionEventId}"); + + // Upsert: (PlayerId, Key) is unique, and a boost can legitimately be + // bought more than once — a blind Add threw + // "UNIQUE constraint failed" and turned the second purchase into a 500 + // AFTER the currency had already been taken. + var progressKey = $"progressionEvent:{req.ProgressionEventId}:xpBoost:{req.PurchasableXpBoostId:N}"; + var progress = await db.ObjectiveProgress + .FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == progressKey); + if (progress is null) { db.ObjectiveProgress.Add(new ObjectiveProgressEntity { PlayerId = pid, - Key = key, + Key = progressKey, IsCompleted = true, ClearedAt = DateTime.UtcNow, }); - await db.SaveChangesAsync(); } - return Ok(BalanceUpdateResponse(newBalance, currencyType, null, null)); - } - - [HttpPost("api/storefronts/v1/buyForFreeGiftButton")] - [HttpGet("api/storefronts/v1/buyForFreeGiftButton")] - public async Task BuyForFreeGiftButton([FromQuery] int currencyType = 2, [FromQuery] int price = 0) - { - var pid = Me; - var balance = await level.GetBalanceAsync(pid, currencyType); - if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType)); - var newBalance = price == 0 - ? balance - : await level.GrantCurrencyAsync(pid, currencyType, -price, "freeGiftButton"); - db.GiftPackages.Add(new GiftPackageEntity + else { - RecipientPlayerId = pid, - CurrencyType = 2, - Currency = 25, - Level = 1, - PackageVariant = "FreeGiftButton", - PackageMaterial = string.Empty, - IsValid = true, - SupportsCurrentPlatform = true, - }); + progress.IsCompleted = true; + progress.ClearedAt = DateTime.UtcNow; + } await db.SaveChangesAsync(); - return Ok(BalanceUpdateResponse(newBalance, currencyType)); + + return Ok(XpBoostResponse(newBalance, currencyType, xp)); } - [HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")] - [HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")] - public async Task BuyProgressionEventXpBoost([FromQuery] int currencyType = 2, [FromQuery] int price = 0) + /// Form body of buyPurchaseReminder, verified at + /// RecNet.Runtime/DCFKEFHJAGC.txt:10203 (purchaseReminderId, Int32) + /// and :10215 (requestedPrice, Int64). + public sealed class PurchaseReminderRequest + { + public int PurchaseReminderId { get; set; } + public long RequestedPrice { get; set; } + } + + /// POST api/storefronts/v1/buyPurchaseReminder — buying the + /// item a "you were looking at this" reminder popup advertises. + /// + /// Was a stub returning {Success:true}; the client deserialises + /// JNPGKDJJPPM = {Balance:Int64, CurrencyType, BalanceType, Data: + /// List<GiftPackage>} (signature at DCFKEFHJAGC.txt:9928, keys at + /// RecNet.Runtime/OLIHBGPDPHF.txt:319/338/362/386), so every field + /// defaulted and the wallet read 0. Note this DTO really does spell its + /// third member BalanceType, unlike the BalanceUpdateResponse family + /// which renames the same property to Platform. + /// + /// There is no reminder catalogue on a private server — the offer feed + /// (reminder/currentTokenBundles/v2) is genuinely empty — so the + /// reminder id is resolved as the StoreItems row it points at. Unknown ids + /// charge nothing and return an empty Data list. + [HttpPost("api/storefronts/v1/buyPurchaseReminder")] + [HttpGet("api/storefronts/v1/buyPurchaseReminder")] + public async Task BuyPurchaseReminder( + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] + PurchaseReminderRequest req, + [FromQuery] int currencyType = 2) { var pid = Me; var balance = await level.GetBalanceAsync(pid, currencyType); - if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType)); - var newBalance = price == 0 + var item = await db.StoreItems + .FirstOrDefaultAsync(s => s.Id == req.PurchaseReminderId && s.IsActive); + if (item is null) + return Ok(PurchaseReminderResponse(balance, currencyType, null, null)); + // Stale-price guard; 0 means the popup didn't quote a price. + if (req.RequestedPrice > 0 && req.RequestedPrice != item.Price) + return Ok(PurchaseReminderResponse(balance, currencyType, null, null)); + if (balance < item.Price) + return Ok(PurchaseReminderResponse(balance, currencyType, null, null)); + + var newBalance = item.Price == 0 ? balance - : await level.GrantCurrencyAsync(pid, currencyType, -price, "progressionEventXpBoost"); + : await level.GrantCurrencyAsync(pid, currencyType, -item.Price, + $"buyPurchaseReminder:{item.Slug}"); + var giftId = await GrantWithGiftBoxAsync(pid, item, currencyType); + db.ObjectiveProgress.Add(new ObjectiveProgressEntity { PlayerId = pid, - Key = $"progressionEvent:xpBoost:{Guid.NewGuid():N}", + Key = $"purchaseReminder:{req.PurchaseReminderId}", IsCompleted = true, ClearedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); - return Ok(BalanceUpdateResponse(newBalance, currencyType)); - } - [HttpPost("api/storefronts/v1/buyPurchaseReminder")] - [HttpGet("api/storefronts/v1/buyPurchaseReminder")] - public async Task BuyPurchaseReminder() - { - db.ObjectiveProgress.Add(new ObjectiveProgressEntity - { - PlayerId = Me, - Key = $"purchaseReminder:{Guid.NewGuid():N}", - IsCompleted = true, - ClearedAt = DateTime.UtcNow, - }); - await db.SaveChangesAsync(); - return Ok(new { Success = true }); + return Ok(PurchaseReminderResponse(newBalance, currencyType, item.Slug, giftId)); } // ── Helpers ────────────────────────────────────────────────────────── @@ -438,6 +763,53 @@ public class StorefrontsBuyController( // GiftPackageEntity.Id. The previous code used // DateTime.UtcNow.Ticks as a fake id. var giftId = giftPackageId ?? DateTime.UtcNow.Ticks; + + return new + { + // BalanceResponseDTO required trio. + Balance = balance, + CurrencyType = currencyType, + // The 2023-03-21 build renames the base DTO's third member to + // "Platform" on the whole BalanceUpdateResponse family — its + // generated contract table lists exactly BalanceUpdates / Balance / + // CurrencyType / Platform and has NO "BalanceType" + // (RecNet.Runtime/KILGEFOENFN.txt:319/346/362/386, identical in + // BBEFAELEANL/IBAPHBCOBHL/ALGKCAKCFAJ/AIEJFLGKIGN). Emitting only + // BalanceType left Platform defaulted to 0 on the client. Both are + // shipped: the 2020.12 watch reads BalanceType and unknown members + // are ignored on both readers. + Platform = 0, + BalanceType = 0, + // PurchaseBalanceUpdateResponseDTO.BalanceUpdates = + // List>. Note the Data member here is + // a LIST (…_PurchaseBalanceUpdateResponseDTO`1_NestedType_PKKNEGNNABE.txt:83) + // — the plain BalanceUpdateResponseDTO family uses a single object. + BalanceUpdates = new[] + { + new + { + UpdateResponse = 0, // UpdateResponseTypes.Success + Data = grantedSlug is null + ? Array.Empty() + : new object[] { GiftPackageWire(giftId, currencyType, grantedSlug) }, + }, + }, + }; + } + + /// One FFFIMAGLKEG/FHMABOHAEED gift package. + /// + /// REQUIRED via Util.GetKey — KeyNotFoundException on miss. Verified keys at + /// NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946 and re-confirmed against + /// the 2023-03-21 contract table RecNet.Runtime/FKANCKLCCDI.txt:1051-1374: + /// Id, Message, FromPlayerId, AvatarItemType, AvatarItemDesc, + /// ConsumableItemDesc, EquipmentPrefabName, EquipmentModificationGuid, + /// Platform, PlatformsToSpawnOn, BalanceType, CurrencyType, Currency, Xp, + /// GiftRarity, GiftContext. Missing any → "Failed to download gifts: + /// Malformed Response" (and on a purchase path, the buy times out because + /// the response never deserializes). + private static object GiftPackageWire(long giftId, int currencyType, string? grantedSlug) + { // Pull the GUID off the slug for wardrobe-* purchases so the // watch's GiftPackage receives the AvatarItem GUID and can // render the unlock animation. dorknet-* slugs leave the desc @@ -460,17 +832,8 @@ public class StorefrontsBuyController( } } - var giftPackage = new + return new { - // REQUIRED via Util.GetKey — KeyNotFoundException on miss. - // Verified keys at NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946: - // Id, Message, FromPlayerId, AvatarItemType, AvatarItemDesc, - // ConsumableItemDesc, EquipmentPrefabName, - // EquipmentModificationGuid, Platform, PlatformsToSpawnOn, - // BalanceType, CurrencyType, Currency, Xp, GiftRarity, - // GiftContext. Missing any → "Failed to download gifts: - // Malformed Response" (and on a purchase path, the buy - // times out because the response never deserializes). Id = giftId, Platform = 0, PlatformsToSpawnOn = -1, @@ -493,28 +856,142 @@ public class StorefrontsBuyController( Consumed = false, ErrorMessage = string.Empty, }; + } + /// Bare BalanceResponseDTO — {Balance, CurrencyType, + /// Platform} and nothing else, per the generated contract at + /// RecNet.Runtime/PECGEJAAMHB.txt:255/274/298. BalanceType is kept + /// alongside Platform for the 2020.12 watch, which reads that spelling. + /// + private static object BalanceResponse(long balance, int currencyType) => new + { + Balance = balance, + CurrencyType = currencyType, + Platform = 0, + BalanceType = 0, + }; + + /// BalanceUpdateResponseDTO`1<PurchasedProgressionEventXpBoostDTO>. + /// Data is a SINGLE object here, not a list — see the BuyProgressionEventXpBoost + /// remarks for the ISIL citation. + private static object XpBoostResponse(long balance, int currencyType, int xp) => new + { + Balance = balance, + CurrencyType = currencyType, + Platform = 0, + BalanceType = 0, + BalanceUpdates = new[] + { + new + { + UpdateResponse = 0, // UpdateResponseTypes.Success + Data = new { Xp = xp }, + }, + }, + }; + + /// JNPGKDJJPPM — the buyPurchaseReminder response. + /// {Balance, CurrencyType, BalanceType, Data:List<GiftPackage>} + /// (RecNet.Runtime/OLIHBGPDPHF.txt:319/338/362/386). "Platform" is + /// emitted as an inert alias because this one DTO is the odd one out that + /// really does use "BalanceType". + private static object PurchaseReminderResponse(long balance, int currencyType, + string? grantedSlug, long? giftPackageId) => new + { + Balance = balance, + CurrencyType = currencyType, + BalanceType = 0, + Platform = 0, + Data = grantedSlug is null + ? Array.Empty() + : new[] { GiftPackageWire(giftPackageId ?? DateTime.UtcNow.Ticks, currencyType, grantedSlug) }, + }; + + // InventionResponse.Status values, from the name-preserved enum + // ENOCILAAKGN in the 2023.06.21 dump + // (Recnet-old/dist/RecRoom-2023.06.21-steam/il2cppdump/dump.cs:1268793-1268840). + private const int InventionStatusSuccess = 0; + private const int InventionStatusInvalidParameters = 1; + private const int InventionStatusDoesNotExist = 7; + private const int InventionStatusPurchaseFailed = 30; + + private static object InventionPurchaseResponse(int status, + InventionEntity? inv, InventionVersionEntity? version, + long balance, int currencyType) + { + object? inventionWire = inv is null ? null : InventionWire(inv); + object? versionWire = version is null ? null : InventionVersionWire(version); return new { - // BalanceResponseDTO required trio. - Balance = balance, - CurrencyType = currencyType, - BalanceType = 0, - // PurchaseBalanceUpdateResponseDTO.BalanceUpdates = - // List>. - BalanceUpdates = new[] + InventionResponse = new { - new - { - UpdateResponse = 0, // UpdateResponseTypes.Success - Data = grantedSlug is null - ? Array.Empty() - : new object[] { giftPackage }, - }, + Status = status, + Invention = inventionWire, + InventionVersion = versionWire, + }, + // InventionPurchaseResponseDTO/EPFIAEMPKJK is + // BalanceUpdateResponseDTO`1 — it extends + // BalanceResponseDTO (BalanceUpdates at +32) and its inner Data is + // an Invention, not a balance delta, so the list is left empty and + // the client takes the balance off the outer trio. + BalanceUpdateResponse = new + { + Balance = balance, + CurrencyType = currencyType, + Platform = 0, + BalanceType = 0, + BalanceUpdates = Array.Empty(), }, }; } + /// Local copy of InventionsController's invention wire shape — + /// that builder is private to its own controller, and the buyInvention + /// response has to carry the same object. Keep the two in sync; the field + /// list is documented there (RecNet.Runtime/IOEPPCKGBFL.txt:1447-1939 is the + /// 2023-03-21 key table). + private static object InventionWire(InventionEntity i) => new + { + InventionId = i.Id, + ReplicationId = string.IsNullOrEmpty(i.ReplicationId) + ? Guid.Empty.ToString("D") : i.ReplicationId, + CreatorPlayerId = (int)i.CreatorPlayerId, + i.Name, + i.Description, + i.ImageName, + i.CurrentVersionNumber, + i.IsPublished, + AllowTrial = true, + ModifiedAt = i.UpdatedAt, + i.CreatedAt, + i.FirstPublishedAt, + i.CreationRoomId, + i.NumPlayersHaveUsedInRoom, + NumDownloads = i.SpawnCount, + i.CheerCount, + i.CreatorPermission, + i.GeneralPermission, + IsAGInvention = i.IsAgInvention, + i.Price, + HideFromPlayer = false, + }; + + /// Local copy of InventionsController's version wire shape. + /// The 2023-03-21 reader also looks for ChipsCost, CloudVariablesCost and + /// BlobHash (RecNet.Runtime/OLPHKLCPFEF.txt:786/810/858); none has a column + /// on InventionVersionEntity, so they are omitted rather than emitted as + /// permanent zeros/nulls. + private static object InventionVersionWire(InventionVersionEntity v) => new + { + v.InventionId, + ReplicationId = string.IsNullOrEmpty(v.ReplicationId) + ? Guid.Empty.ToString("D") : v.ReplicationId, + v.VersionNumber, + v.InstantiationCost, + v.LightsCost, + v.BlobName, + }; + private static object RoomKeyPurchaseResponse(RoomKeyStatus status, RoomKeyEntity? key, long balance) => new { RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key), @@ -522,8 +999,111 @@ public class StorefrontsBuyController( { Balance = balance, CurrencyType = 2, + Platform = 0, BalanceType = 0, BalanceUpdates = Array.Empty(), }, }; + + /// RoomKeyPurchaseWithCurrencyResponseDTO — {Balance, + /// RoomKeyResponse} where Balance is the ROOM-currency balance row + /// (RecNet.Runtime/EDMKBBPGJAB.txt:203/222, member shape at + /// DPNLANKOHON.txt:331-414), matching what + /// RoomCurrenciesController already emits for the same row. + private static object RoomKeyCurrencyPurchaseResponse( + RoomKeyStatus status, RoomKeyEntity? key, long playerId, Guid currencyId, + RoomCurrencyBalanceEntity? row) => new + { + Balance = new + { + AccountId = (int)playerId, + CurrencyId = currencyId, + Balance = row?.Balance ?? 0L, + ModifiedAt = row is null + ? DateTime.UtcNow + : DateTime.SpecifyKind(row.UpdatedAt, DateTimeKind.Utc), + }, + RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key), + }; + + /// Grants a purchased StoreItems row. Wardrobe unlocks are a set, + /// so a multi-count press still yields one entry; everything else stacks in + /// PlayerInventory. + private async Task GrantPurchasedItemAsync(long pid, StoreItemEntity item, int count = 1) + { + if (StoreService.TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc)) + { + await GrantWardrobeAsync(pid, StoreService.InventoryAvatarItemDesc(avatarItemDesc)); + return; + } + for (var n = 0; n < Math.Max(1, count); n++) + await GrantInventoryAsync(pid, item.Slug); + } + + /// Grants and inserts the matching + /// gift-box row, mirroring BuyItem: the watch pops a gift box after the + /// purchase and POSTs the package id to /api/avatar/v2/gifts/consume/, so + /// the id in the response has to be a real GiftPackageEntity. + private async Task GrantWithGiftBoxAsync(long pid, StoreItemEntity item, int currencyType) + { + await GrantPurchasedItemAsync(pid, item); + + StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out var avatarItemDesc); + StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc); + StoreService.TryGetEquipmentPayload(item.Slug, out var equipmentPrefabName, out var equipmentModificationGuid); + var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc); + if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid)) + { + avatarItemType = 1; + inventoryAvatarDesc = hairDyeColorGuid; + consumableItemDesc = hairDyeConsumableDesc; + } + // Same 4-part "{guid},,," desc BuyItem ships — the bare guid makes the + // client's AvatarItem parser miss on unlock. + var giftWireDesc = string.IsNullOrEmpty(avatarItemDesc) ? inventoryAvatarDesc : avatarItemDesc; + + var gift = new GiftPackageEntity + { + RecipientPlayerId = pid, + AvatarItemType = string.IsNullOrEmpty(giftWireDesc) ? null : avatarItemType, + AvatarItemDescOrHairDyeDesc = giftWireDesc, + ConsumableItemDesc = consumableItemDesc, + EquipmentPrefabName = equipmentPrefabName, + EquipmentModificationGuid = equipmentModificationGuid, + CurrencyType = currencyType, + Currency = 0, + Xp = 0, + Level = 1, + GiftContext = 0, + GiftRarity = 0, + Message = string.Empty, + Platform = -1, + PackageVariant = "Standard", + PackageMaterial = string.Empty, + Consumed = false, + IsValid = true, + SupportsCurrentPlatform = true, + }; + db.GiftPackages.Add(gift); + await db.SaveChangesAsync(); + return gift.Id; + } + + /// Idempotently records a season-pass purchase. There is no + /// SeasonProgressEntity, so tier/elite state lives on ObjectiveProgress + /// under seasonTier:{storefrontType}:{purchasableItemId} and + /// seasonElite:{storefrontType} — the same table the season + /// objectives feed already uses. + private async Task RecordSeasonProgressAsync(long pid, string key) + { + if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key)) return; + db.ObjectiveProgress.Add(new ObjectiveProgressEntity + { + PlayerId = pid, + Key = key, + IsCompleted = true, + ClearedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + } } diff --git a/DorkNet.Server/Controllers/API/Subscriptions/SubscriptionsController.cs b/DorkNet.Server/Controllers/API/Subscriptions/SubscriptionsController.cs new file mode 100644 index 0000000..127d22b --- /dev/null +++ b/DorkNet.Server/Controllers/API/Subscriptions/SubscriptionsController.cs @@ -0,0 +1,194 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Binding; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.Subscriptions; + +/// +/// The creator-subscription mutation pair the 2023-03-21 client hits from a +/// profile card's Subscribe button. Both live at the bare two-segment route +/// subscription/{accountId} on the Clubs host (enum value 13); the +/// route literal is "subscription/{0}" formatted with a boxed +/// System.Int32 account id at +/// RecNet.Runtime/IKMMOCKDKAF.txt:6551 (POST, verb rdx=2) and +/// :6862 (DELETE, verb rdx=4). Callers are the account-model wrappers +/// OBEEEEAMLJI.BDJCLAGIHBN/LPFOPPHGDHG(Int32 accountId, …) +/// (Assembly-CSharp/OBEEEEAMLJI.txt:644,793) behind +/// AGUI/StackedUI/SubscribeConditionalButton, so the path segment is +/// always a player account id — never a club id. +/// +/// The GET side of this surface already exists but lives with the club reads +/// (ClubsController: subscription/details/{id}, +/// subscription/subscriberCount/{id}, subscription/mine/member) +/// and PlayerSubscriptionsController +/// (subscription/top/creators/today). Only the two writes were missing, +/// which made every subscribe/unsubscribe 404 and surface the client's +/// "Failed to subscribe to {0}" / "Failed to unsubscribe from {0}" +/// toast (format literals at IKMMOCKDKAF.txt:6663 and the DELETE's +/// matching continuation). +/// +/// Neither response body is parsed: the client's continuation is a bare +/// Func<IPCJLCNIBEG<Int64>, …> whose only use is the +/// success/failure branch, after which it invalidates its subscription cache +/// and re-reads subscription/mine/member + subscription/details. +/// Any 2xx is therefore sufficient — but the reads it follows up with must +/// reflect the write, which is why these handlers touch BOTH subscription +/// tables (see ). +/// +[ApiController] +[Authorize] +public class SubscriptionsController( + DorkNetDbContext db, + ClubService clubs, + ILogger logger) : ControllerBase +{ + private long Me => this.RequireCurrentPlayerId(); + + /// + /// Body of the subscribe POST. The client appends exactly one + /// form-urlencoded field via BNDIAONDFFF.AFGEDDANEKP("roomId", …) + /// (IKMMOCKDKAF.txt:6726 — Move rdx, "roomId"), boxed from an + /// ObscuredLong holding the room the player is standing in. The + /// preceding compare against -1 (:6713, + /// Compare rax, 18446744073709551615 → jump past the append) means + /// the field is OMITTED entirely when the player is not in a room, so it + /// must be optional. Unsubscribe sends no body at all. + /// + public sealed class SubscribeRequest + { + public long RoomId { get; set; } = -1; + } + + /// + /// POST /subscription/{accountId} — subscribe to a creator. + /// + /// Writes two rows, because the 2023 client's post-subscribe refresh reads + /// two differently-sourced endpoints and both have to agree: + /// * (player→player) is what + /// subscription/details/{id}, subscription/subscriberCount/{id}, + /// subscription/top/creators/today and the PlayerState reputation + /// aggregate all count. This is the canonical row. + /// * is what + /// subscription/mine/member and + /// announcements/v2/subscription/mine/unread read. A creator's + /// "creator club" is the club they own (same resolution + /// subscription/details uses for its ClubId field — oldest + /// id wins), so when the target owns one we mirror the subscription + /// there too. Without the mirror the subscribe succeeds but the + /// creator never appears in the client's own subscribed list and the + /// button flips straight back to "Subscribe" on the next cache refresh. + /// Creators with no club get only the canonical row, which is all their + /// profile card reads. + /// + /// Idempotent: re-subscribing is a 200 no-op rather than a unique-index + /// violation (both tables carry a unique pair index). + /// + [HttpPost("/subscription/{accountId:long}")] + public async Task Subscribe( + long accountId, + [ModelBinder(typeof(FormOrJsonModelBinder))] SubscribeRequest req) + { + var me = Me; + if (accountId == me) return BadRequest(new { Error = "cannot_subscribe_to_self" }); + + var targetExists = await db.Players.AnyAsync(p => p.Id == accountId); + if (!targetExists) return NotFound(new { Error = "account_not_found" }); + + var changed = false; + + var existing = await db.Subscriptions.FirstOrDefaultAsync( + s => s.SubscriberPlayerId == me && s.TargetPlayerId == accountId); + if (existing is null) + { + db.Subscriptions.Add(new SubscriptionEntity + { + SubscriberPlayerId = me, + TargetPlayerId = accountId, + }); + changed = true; + } + + var creatorClubId = await CreatorClubIdAsync(accountId); + if (creatorClubId != 0) + { + var existingClub = await db.ClubSubscriptions.FirstOrDefaultAsync( + s => s.PlayerId == me && s.ClubId == creatorClubId); + if (existingClub is null) + { + db.ClubSubscriptions.Add(new ClubSubscriptionEntity + { + ClubId = creatorClubId, + PlayerId = me, + }); + changed = true; + } + } + + if (changed) await db.SaveChangesAsync(); + + // roomId is pure attribution — "which room was this creator discovered + // in". There is no column for it on SubscriptionEntity and this pass + // adds no schema, so it is recorded in the log rather than silently + // dropped; the subscription itself is fully persisted above. + if (req.RoomId > 0) + { + logger.LogInformation( + "Player {SubscriberId} subscribed to creator {AccountId} from room {RoomId}.", + me, accountId, req.RoomId); + } + + return Ok(); + } + + /// + /// DELETE /subscription/{accountId} — unsubscribe. Same route + /// literal, verb register rdx=4 at + /// IKMMOCKDKAF.txt:6864; the request carries no parameters + /// (AFGEDDANEKP is never called on the builder before the send), + /// so there is nothing to bind. + /// + /// Removes both rows written by so the canonical + /// counts and the subscription/mine/member list drop the creator + /// together. Unsubscribing when not subscribed is a 200 no-op — the client + /// treats any non-2xx as the "Failed to unsubscribe from {0}" toast, and a + /// double-tap of an already-removed subscription is not a failure the + /// player should see. + /// + [HttpDelete("/subscription/{accountId:long}")] + public async Task Unsubscribe(long accountId) + { + var me = Me; + + var row = await db.Subscriptions.FirstOrDefaultAsync( + s => s.SubscriberPlayerId == me && s.TargetPlayerId == accountId); + if (row is not null) db.Subscriptions.Remove(row); + + var creatorClubId = await CreatorClubIdAsync(accountId); + if (creatorClubId != 0) + { + var clubRow = await db.ClubSubscriptions.FirstOrDefaultAsync( + s => s.PlayerId == me && s.ClubId == creatorClubId); + if (clubRow is not null) db.ClubSubscriptions.Remove(clubRow); + } + + await db.SaveChangesAsync(); + return Ok(); + } + + /// + /// The club id subscription/details/{accountId} reports as the + /// creator's ClubId: the oldest live club the account owns, or 0 + /// when they own none. Kept byte-identical to that handler's resolution so + /// subscribing writes the row the details card points the player at. + /// + private async Task CreatorClubIdAsync(long accountId) + { + var created = await clubs.CreatedByAsync(accountId); + return created.Count > 0 ? created.Min(c => c.Id) : 0L; + } +} diff --git a/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs new file mode 100644 index 0000000..78c81b2 --- /dev/null +++ b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs @@ -0,0 +1,287 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Auth; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using DorkNet.Server.Services; + +namespace DorkNet.Server.Controllers.API.TestCaseManagement; + +/// +/// Links QA test cases to GitHub issues. +/// +/// These routes are NOT part of the 2023 client's surface — the in-game +/// QA tab never calls them. They exist for the admin tooling, so they are +/// admin-gated, unlike the client-facing routes in +/// which are deliberately open +/// (the watch gates that tab client-side on the developer role). +/// +/// The issue URL is stored in — +/// the field Rec Room's own tooling used for exactly this, a link to the bug +/// filed against a failing case. Reusing it means no schema change, and on +/// this server that matters more than the name: the Postgres path is +/// EnsureCreated-only and never replays migrations, so every new column has to +/// be added twice or it is missing in production. A field that already exists +/// everywhere has neither problem, and the admin UI already renders it. +/// +[ApiController] +[AdminOnly] +public class TestCaseIssuesController( + DorkNetDbContext db, + IGitHubIssues github, + ILogger log) : ControllerBase +{ + /// Test-case status values, matching the client's + /// TestCaseStatus enum. + private const int StatusFailed = 2; + private const int StatusPassed = 3; + + + /// GET api/admin/v1/testcases — every test case with its + /// issue link, newest pass first. Optional ?passId= narrows to one + /// test pass and ?status= to one status. + /// + /// Exists because the admin SPA speaks /api/admin/v1 exclusively and + /// the client-facing test-case routes are shaped for the in-game QA tab + /// (per-pass, no issue fields). + [HttpGet("api/admin/v1/testcases")] + public async Task ListCases( + [FromQuery] uint? passId, [FromQuery] int? status, CancellationToken ct) + { + var query = db.TestCases.AsQueryable(); + if (passId is uint pass) query = query.Where(c => c.TestPassId == pass); + if (status is int s) query = query.Where(c => c.Status == s); + + var cases = await query + .OrderByDescending(c => c.TestPassId) + .ThenBy(c => c.Key) + .Take(500) + .ToListAsync(ct); + + return Ok(new + { + githubConfigured = github.IsConfigured, + repository = github.Repository, + cases = cases.Select(c => new + { + c.Id, + c.Key, + c.Title, + c.RoomName, + c.Status, + c.TestPassId, + issueUrl = c.JiraBugUrl, + issueNumber = GitHubIssueService.IssueNumberFromUrl(c.JiraBugUrl), + c.UpdatedAt, + }).ToList(), + }); + } + + /// POST api/testcasemanagement/v1/testcase/{id}/issue — + /// file a GitHub issue for a test case and link it. + /// + /// Idempotent: a case that already carries a live issue link returns that + /// issue rather than filing a duplicate. Re-running a sweep over a failing + /// pass is therefore safe, which is the whole point — the alternative is a + /// tester filing the same bug on every run. + [HttpPost("api/testcasemanagement/v1/testcase/{id}/issue")] + [HttpPost("api/admin/v1/testcases/{id}/issue")] + public async Task CreateIssue(string id, CancellationToken ct) + { + if (!github.IsConfigured) + return Problem( + "GitHub issue linking is not configured. Set GitHub:Token and GitHub:Repository.", + statusCode: StatusCodes.Status503ServiceUnavailable); + + var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id, ct); + if (tc is null) return NotFound(); + + if (GitHubIssueService.IssueNumberFromUrl(tc.JiraBugUrl) is int existingNumber) + { + var existing = await github.GetAsync(existingNumber, ct); + // A link to an issue that no longer exists (deleted, or the repo + // changed) must not block filing a new one. + if (existing is not null) + return Ok(Describe(tc, existing, created: false)); + } + + var issue = await github.CreateAsync(TitleFor(tc), BodyFor(tc), LabelsFor(tc), ct); + if (issue is null) + return Problem("GitHub rejected the issue.", statusCode: StatusCodes.Status502BadGateway); + + tc.JiraBugUrl = issue.Url; + tc.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + + log.LogInformation( + "[testcase-issue] filed {Repo}#{Number} for case {Key} ({Id})", + github.Repository, issue.Number, tc.Key, tc.Id); + return Ok(Describe(tc, issue, created: true)); + } + + /// DELETE api/testcasemanagement/v1/testcase/{id}/issue — + /// unlink, leaving the issue itself alone. Closing someone's issue as a + /// side effect of tidying a QA link would be the wrong call. + [HttpDelete("api/testcasemanagement/v1/testcase/{id}/issue")] + [HttpDelete("api/admin/v1/testcases/{id}/issue")] + public async Task UnlinkIssue(string id, CancellationToken ct) + { + var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id, ct); + if (tc is null) return NotFound(); + + var previous = tc.JiraBugUrl; + tc.JiraBugUrl = string.Empty; + tc.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + + log.LogInformation("[testcase-issue] unlinked {Url} from case {Key}", previous, tc.Key); + return Ok(new { tc.Id, tc.Key, unlinked = previous }); + } + + /// POST api/testcasemanagement/v1/issues/sync — reconcile + /// every linked case against its issue. + /// + /// The state a test case should be in follows the issue, in one direction + /// only: + /// + /// + /// Issue CLOSED and the case is Failed → Passed. A closed issue means + /// the bug is fixed, and the case that was failing on it therefore passes. + /// Issue REOPENED and the case is Passed → Failed. The bug came back, + /// so the close transition above is undone. + /// + /// + /// Nothing else moves. Only these two states are the reconciler's to + /// change, so a case a tester has Claimed, or one nobody has run yet, is + /// never rewritten underneath them. + [HttpPost("api/testcasemanagement/v1/issues/sync")] + [HttpPost("api/admin/v1/testcases/issues/sync")] + public async Task Sync(CancellationToken ct) + { + if (!github.IsConfigured) + return Problem( + "GitHub issue linking is not configured. Set GitHub:Token and GitHub:Repository.", + statusCode: StatusCodes.Status503ServiceUnavailable); + + var changed = await ReconcileAsync(db, github, log, ct); + return Ok(new { reconciled = changed.Count, changes = changed }); + } + + /// Shared by the endpoint and the background reconciler so the two + /// can never drift apart. + internal static async Task> ReconcileAsync( + DorkNetDbContext db, IGitHubIssues github, ILogger log, CancellationToken ct) + { + var linked = await db.TestCases + .Where(c => c.JiraBugUrl != string.Empty) + .ToListAsync(ct); + + var changes = new List(); + foreach (var tc in linked) + { + if (ct.IsCancellationRequested) break; + if (GitHubIssueService.IssueNumberFromUrl(tc.JiraBugUrl) is not int number) continue; + + var issue = await github.GetAsync(number, ct); + if (issue is null) continue; + + var closed = issue.State.Equals("closed", StringComparison.OrdinalIgnoreCase); + // Closed means fixed, so a case failing on that bug now passes. + // Only ever move between those two states: reopening undoes the + // close and nothing else. "Open issue and the case isn't Failed" + // would also catch a case a tester has just Claimed, or one nobody + // has run yet, and mark it Failed underneath them. + int? next = (closed, tc.Status) switch + { + (true, StatusFailed) => StatusPassed, + (false, StatusPassed) => StatusFailed, + _ => null, + }; + if (next is not int status) continue; + + var from = tc.Status; + tc.Status = status; + tc.UpdatedAt = DateTime.UtcNow; + changes.Add(new { tc.Id, tc.Key, issue = number, from, to = status }); + log.LogInformation( + "[testcase-issue] case {Key} status {From} -> {To} (issue #{Number} is {State})", + tc.Key, from, status, number, issue.State); + } + + if (changes.Count > 0) await db.SaveChangesAsync(ct); + return changes; + } + + private object Describe(TestCaseEntity tc, GitHubIssue issue, bool created) => new + { + tc.Id, + tc.Key, + tc.Title, + tc.Status, + issue = new { issue.Number, issue.Url, issue.State }, + created, + }; + + private static string TitleFor(TestCaseEntity tc) => + string.IsNullOrWhiteSpace(tc.Key) ? tc.Title : $"[{tc.Key}] {tc.Title}"; + + /// The issue body. Written for whoever picks the bug up, so it + /// leads with what to do rather than with metadata. + private static string BodyFor(TestCaseEntity tc) + { + var lines = new List(); + if (!string.IsNullOrWhiteSpace(tc.Description)) + { + lines.Add(tc.Description.Trim()); + lines.Add(""); + } + + lines.Add("| | |"); + lines.Add("| --- | --- |"); + lines.Add($"| Test case | `{tc.Key}` |"); + if (!string.IsNullOrWhiteSpace(tc.RoomName)) lines.Add($"| Room | {tc.RoomName} |"); + if (tc.TestPassId is uint pass) lines.Add($"| Test pass | {pass} |"); + if (!string.IsNullOrWhiteSpace(tc.JiraUrl)) lines.Add($"| Test case link | {tc.JiraUrl} |"); + + var comments = ParseComments(tc.CommentsJson); + if (comments.Count > 0) + { + lines.Add(""); + lines.Add("### Tester notes"); + foreach (var comment in comments) lines.Add($"- {comment}"); + } + + lines.Add(""); + lines.Add("Filed from DorkNet QA test-case management."); + return string.Join("\n", lines); + } + + private static IEnumerable LabelsFor(TestCaseEntity tc) + { + yield return "qa"; + foreach (var tag in ParseComments(tc.TagsJson)) yield return tag; + } + + /// Both CommentsJson and TagsJson are JSON string arrays. A + /// comment is stored as playerId|timestamp|text; only the text is + /// worth putting in an issue. + private static List ParseComments(string json) + { + try + { + var items = System.Text.Json.JsonSerializer.Deserialize>(json) ?? []; + return items + .Select(item => + { + var parts = item.Split('|', 3); + return (parts.Length == 3 ? parts[2] : item).Trim(); + }) + .Where(text => text.Length > 0) + .ToList(); + } + catch (System.Text.Json.JsonException) + { + return []; + } + } +} diff --git a/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseManagementController.cs b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseManagementController.cs index c33a086..34b5f99 100644 --- a/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseManagementController.cs +++ b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseManagementController.cs @@ -141,6 +141,74 @@ public class TestCaseManagementController(DorkNetDbContext db) : ControllerBase return Ok(ToWireCase(tc)); } + /// POST api/testcasemanagement/v1/testcase/{id}/comment — + /// attach a QA note to a test case. + /// + /// The client builds the URL by concatenating + /// "api/testcasemanagement/v1/testcase/" + id + "/comment" + /// (RecNet.Runtime/FLODOBMEJAO.txt:660, :658) and posts it with verb + /// ordinal 2 (Move rdx, 2 at :673). The issuing method is + /// LDGADANDBIO KNDOKAKBLCC(String, String) (:575) — a status-only + /// promise, so the client parses no response DTO and only needs a 2xx. + /// + /// Comments append to a JSON list on the case rather than overwriting, so + /// a second tester's note never erases the first. + [HttpPost("api/testcasemanagement/v1/testcase/{id}/comment")] + [Authorize] + [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] + public async Task AddComment(string id) + { + var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id); + if (tc is null) return NotFound(); + + var text = (await ReadCommentAsync())?.Trim(); + if (string.IsNullOrEmpty(text)) return BadRequest("missing comment"); + if (text.Length > 2000) text = text[..2000]; + + var comments = ParseStringList(tc.CommentsJson); + comments.Add($"{this.RequireCurrentPlayerId()}|{DateTime.UtcNow:O}|{text.Replace('|', '/')}"); + tc.CommentsJson = JsonSerializer.Serialize(comments); + tc.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return Ok(); + } + + /// The comment text, from a form field, the query string, a JSON + /// object, or a bare JSON string body — the exact spelling is not + /// recoverable from the ISIL, so every plausible carrier is accepted. + private async Task ReadCommentAsync() + { + string[] names = ["comment", "Comment", "text", "Text", "body", "Body", "message", "Message"]; + + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var n in names) + if (form.TryGetValue(n, out var v) && v.Count > 0) return v.ToString(); + } + foreach (var n in names) + if (Request.Query.TryGetValue(n, out var q) && q.Count > 0) return q.ToString(); + + if (!Request.HasFormContentType) + { + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await JsonDocument.ParseAsync(Request.Body); + Request.Body.Position = 0; + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.String) return root.GetString(); + if (root.ValueKind == JsonValueKind.Object) + foreach (var n in names) + if (root.TryGetProperty(n, out var v) && v.ValueKind == JsonValueKind.String) + return v.GetString(); + } + catch (JsonException) { /* no usable body */ } + } + return null; + } + // ── Helpers ────────────────────────────────────────────────────────── private static List ParseIntList(string? json) diff --git a/DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs b/DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs index 7c34ac7..46e026b 100644 --- a/DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs +++ b/DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs @@ -11,9 +11,17 @@ namespace DorkNet.Server.Controllers.API.UgcPurchasables; [ApiController] public class UgcPurchasablesController(DorkNetDbContext db) : ControllerBase { + /// The 2023-03-21 client asks for a room's in-room store with the + /// path form api/ugcPurchasables/v1/items/room/{roomId}, not the + /// ?roomId= query this handler was originally written for. Without + /// the path route it 404'd and the in-room UGC store never populated on + /// room join. + [HttpGet("api/ugcPurchasables/v1/items/room/{roomId:long}")] [HttpGet("api/ugcPurchasables")] [AllowAnonymous] - public async Task List([FromQuery] long roomId = 0) + // No explicit binding source: roomId comes from the route on the path form + // above and from the query string on the bare route. + public async Task List(long roomId = 0) { var rows = await db.UgcPurchasables .Where(i => !i.IsDeleted && (roomId <= 0 || i.RoomId == roomId)) diff --git a/DorkNet.Server/Controllers/Accounts/AccountsController.cs b/DorkNet.Server/Controllers/Accounts/AccountsController.cs index 9baa336..96970fc 100644 --- a/DorkNet.Server/Controllers/Accounts/AccountsController.cs +++ b/DorkNet.Server/Controllers/Accounts/AccountsController.cs @@ -26,6 +26,7 @@ public class AccountsController( DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings, + PlayerPresenceService presence, ILogger logger) : ControllerBase { [HttpGet("/account/v1/savedlogins")] @@ -62,6 +63,102 @@ public class AccountsController( return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName)); } + /// + /// DELETE /account/me — the 2023 client's "delete local account" + /// (RecNet.Runtime OPMAPIOEIFG.ADFMLPGEBOF()). Binary evidence: + /// the request object is built at OPMAPIOEIFG.txt:8767-8777 with the + /// verb ordinal in rdx = 4 (BestHTTP.HTTPMethods.Delete) and the + /// path literal "account/me" at :8771; the failure branch at + /// :8790 carries the string "Failed to delete local account". + /// The issuing method returns LDGADANDBIO, the fire-and-forget + /// promise handle — the response BODY is never parsed, only the HTTP + /// status decides success — so the RecNetResult we emit is purely for + /// human/curl consumption. Registering only [HttpGet] on this + /// path (the previous state) produced a 405 and the in-game + /// "delete my account" button silently failed. + /// + /// Erasure runs through the same + /// cascade the admin SPA uses, inside one transaction: personal rows + /// are deleted, authored durable content is reassigned to the system + /// account, then the Players row goes and the live session is kicked + /// so the bearer token stops being useful immediately. + /// + [HttpDelete("/account/me")] + [HttpDelete("/account/v1/me")] + [HttpDelete("/account/v2/me")] + public async Task DeleteMe() + { + var player = await GetCurrentPlayerAsync(); + if (player is null) return Unauthorized(); + + // The system account (id 1) owns every reassigned artefact — losing + // it would orphan the whole DB — and admin accounts must be demoted + // through the SPA first so a compromised session can't nuke the + // server's only operator. Both refusals are surfaced as a non-2xx so + // the client's promise rejects and the player sees the failure + // rather than a phantom success. + if (player.Id == 1) + { + logger.LogWarning("[accounts] DELETE account/me refused — system account"); + return BadRequest(new RecNetResult + { + Success = false, + Error = "The system account cannot be deleted.", + }); + } + if (player.IsAdmin) + { + logger.LogWarning( + "[accounts] DELETE account/me refused — {Id} ({Username}) is an admin", + player.Id, player.Username); + return BadRequest(new RecNetResult + { + Success = false, + Error = "Admin accounts cannot self-delete. Demote the account first.", + }); + } + + var playerId = player.Id; + var username = player.Username; + + // Authored content is reassigned rather than destroyed. Preference + // order: the system account (id 1), else the lowest-id surviving + // account, else 0 (single-account server — nothing left to own it). + var systemPlayerId = await db.Players.AnyAsync(p => p.Id == 1) + ? 1L + : await db.Players.Where(p => p.Id != playerId) + .OrderBy(p => p.Id) + .Select(p => p.Id) + .FirstOrDefaultAsync(); + + // GetCurrentPlayerAsync goes through PlayerService.GetByIdAsync, + // which Include()s the Avatar and leaves it tracked on this same + // scoped DbContext. PurgeAsync deletes that avatar row with + // ExecuteDelete, so leaving it tracked would make the Players.Remove + // cascade emit a second DELETE for an already-gone row and throw + // DbUpdateConcurrencyException. Drop the graph, then re-read the + // Players row on its own. + db.ChangeTracker.Clear(); + var row = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); + if (row is null) return Ok(new RecNetResult { Success = true, Error = string.Empty }); + + await using (var tx = await db.Database.BeginTransactionAsync()) + { + var summary = await PlayerAccountPurge.PurgeAsync(db, playerId, systemPlayerId); + db.Players.Remove(row); + await db.SaveChangesAsync(); + await tx.CommitAsync(); + logger.LogInformation( + "[accounts] DELETE account/me — removed {Id} ({Username}); {Summary}", + playerId, username, summary); + } + + await notifications.KickPlayerAsync(playerId, "This account has been deleted."); + presence.Clear(playerId); + + return Ok(new RecNetResult { Success = true, Error = string.Empty }); + } + /// /// Resolves the player tied to the bearer token on the request. Returns /// null when no/invalid bearer is supplied — the caller decides whether @@ -175,11 +272,18 @@ public class AccountsController( [HttpGet("/account/search")] [HttpGet("/account/v1/search")] [Microsoft.AspNetCore.Authorization.AllowAnonymous] - public async Task Search([FromQuery] string? query, [FromQuery] int take = 20) + // The 2023-03-21 client names the search term "name"; binding only "query" + // meant every player search came back empty (the handler short-circuited on + // a null term before it ever reached the service). + public async Task Search( + [FromQuery] string? query, + [FromQuery] string? name, + [FromQuery] int take = 20) { - if (string.IsNullOrWhiteSpace(query)) return Ok(Array.Empty()); + var term = !string.IsNullOrWhiteSpace(name) ? name : query; + if (string.IsNullOrWhiteSpace(term)) return Ok(Array.Empty()); var clamped = Math.Clamp(take, 1, 50); - var matches = await playerService.SearchAsync(query.Trim(), clamped); + var matches = await playerService.SearchAsync(term.Trim(), clamped); return Ok(matches .Select(p => BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName)) .ToList()); @@ -338,6 +442,10 @@ public class AccountsController( /// cutoff is <13 years old; we recompute it on every birthday /// write so a freshly turned 13 player loses the junior flag the /// next time they update. + // The 2023 client PUTs birthday (verb 3 at OPMAPIOEIFG.txt:7950-7954); + // POST-only returned 405 and the birthday step of onboarding always failed. + [HttpPut("/account/v1/birthday")] + [HttpPut("/account/me/birthday")] [HttpPost("/account/v1/birthday")] [HttpPost("/account/me/birthday")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] @@ -681,8 +789,103 @@ public class AccountsController( return Ok(new { accountId = (int)accountId, bio = p?.Bio ?? string.Empty }); } + /// + /// GET /platformid/{accountId} — the 2023 client's + /// OPMAPIOEIFG.DPLOCBLKCEB(Int32 accountId). Binary evidence: + /// path literal "platformid/{0}" at OPMAPIOEIFG.txt:11056 with + /// the verb ordinal rdx = 0 (GET) at :11066, and the failure + /// string "Failed to GetPlatformId for accountId {0}" at :11102. + /// + /// RESPONSE SHAPE: the issuing method's return type is + /// FGLDKEJLAKB<System.String> — a BARE JSON string, not an + /// object. Emitting {"PlatformId":0} makes the strict reader hit + /// '{' where it wants '"' and throw. The legacy + /// /account/v1/{id}/platformid spelling is kept as an alias and + /// now returns the real stored value instead of a hardcoded 0. + /// + /// The client short-circuits and never issues the request when + /// accountId <= 0 (:11052-11053), so we only ever see real ids. + /// Unknown accounts get an empty string — "no platform identity" is the + /// honest answer and keeps the promise resolved rather than rejected. + /// + [HttpGet("/platformid/{accountId:long}")] [HttpGet("/account/v1/{accountId:long}/platformid")] - public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L }); + public async Task GetPlatformId(long accountId) + { + var platformId = await db.Players + .Where(p => p.Id == accountId) + .Select(p => p.LastPlatformId) + .FirstOrDefaultAsync(); + return Content( + System.Text.Json.JsonSerializer.Serialize(platformId ?? string.Empty), + "application/json"); + } + + /// + /// GET /accounts/{accountId}/receives/{category} — the 2023 + /// client's platform-notification opt-in probe + /// (LELAJKMOMIA.GCIPPNCOKAJ(Int32 accountId)). Binary evidence: + /// path literal at LELAJKMOMIA.txt:695, verb ordinal rdx = 0 + /// (GET) at :705, service-host ordinal 15 (platformnotifications) at + /// :709. The {1} segment is a CCOKJMJOIJF enum value + /// boxed and String.Formated at :690-698, i.e. the enum MEMBER + /// NAME — the constant at this call site is value 8 (:693) but the + /// member names appear nowhere as literals in the binary, so the + /// segment MUST stay an unconstrained opaque string. + /// + /// RESPONSE SHAPE: the continuation is System.Action<Boolean> + /// over FGLDKEJLAKB<Boolean> (:721/:733) — a BARE JSON + /// boolean, no envelope. A 404 here rejects the promise inside AGUI's + /// AccountSpecificUIBehaviour and blocks the notification-gated action. + /// + /// Answered from the real row that + /// backs PUT /preferences: AllowAll gates everything, then + /// the per-category flag. Categories are resolved by the same ids the + /// platformnotifications config/categories catalogue publishes + /// (1 Messages, 2 Friend Requests, 3 Event Invites, 4 Announcements) and + /// by normalised name, so both the numeric and member-name spellings + /// land on the right column. A category we don't model (such as the + /// value-8 one this call site uses) falls back to AllowAll — the + /// player has no mute for it, which is the truthful answer, not a stub. + /// + [HttpGet("/accounts/{accountId:long}/receives/{category}")] + [HttpGet("/platformnotifications/accounts/{accountId:long}/receives/{category}")] + public async Task Receives(long accountId, string category) + { + var exists = await db.Players.AnyAsync(p => p.Id == accountId); + if (!exists) return Content("false", "application/json"); + + var prefs = await db.NotificationPrefs + .FirstOrDefaultAsync(p => p.PlayerId == accountId); + // No row at all = never opted out of anything. + if (prefs is null) return Content("true", "application/json"); + + var receives = prefs.AllowAll && CategoryAllowed(prefs, category); + return Content(receives ? "true" : "false", "application/json"); + } + + /// Maps the opaque {category} segment onto the columns + /// of . Accepts the numeric + /// CategoryId published by config/categories as well as the enum + /// member name in any casing/punctuation; anything we don't model is + /// only gated by AllowAll. + private static bool CategoryAllowed(NotificationPrefsEntity prefs, string? category) + { + if (string.IsNullOrWhiteSpace(category)) return true; + var key = new string(category.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); + return key switch + { + "1" or "message" or "messages" or "directmessage" or "directmessages" + => prefs.AllowMessage, + "2" or "friendrequest" or "friendrequests" + => prefs.AllowFriendRequest, + "3" or "eventinvite" or "eventinvites" or "invite" or "invites" + => prefs.AllowEventInvite, + "4" or "announcement" or "announcements" or "news" + => prefs.AllowAnnouncements, + _ => true, + }; + } [HttpGet("/accountprivacysettings/{accountId:long}")] [HttpGet("/account/v1/privacysettings/{accountId:long}")] diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index c0ae192..40a8195 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1097,6 +1097,81 @@ public class AdminController( }); } + /// GET api/admin/v1/rooms/{id}/subrooms — every sub-room of + /// a room with the settings that are adjustable per sub-room. + /// + /// Max players is genuinely per sub-room: the room's own + /// is what the room advertises and what + /// matchmaking hands to RoomInstance.MaxCapacity, while each sub-room caps + /// itself through . In-game the + /// owner drives this from the "Max Player Count in This Subroom" slider + /// (PUT rooms/{id}/subrooms/{sub}/maxplayers); this is the same setting, + /// reachable for rooms an admin does not own. + [HttpGet("rooms/{id:long}/subrooms")] + public async Task GetRoomSubRooms(long id) + { + if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound(); + + var scenes = await db.RoomScenes + .Where(s => s.RoomId == id) + .OrderBy(s => s.OrderIndex) + .ToListAsync(); + + return Ok(scenes.Select(s => new + { + // Ids go out as strings: the admin SPA is JavaScript and silently + // mangles integers past 2^53. + id = s.Id.ToString(), + subRoomId = s.OrderIndex, + name = s.Name, + maxPlayers = s.MaxPlayers, + canMatchmakeInto = s.CanMatchmakeInto, + isSandbox = s.IsSandbox, + dataBlobName = s.DataBlobName, + dataModifiedAt = s.DataModifiedAt, + }).ToList()); + } + + public sealed record SetSubRoomMaxPlayersRequest(int MaxPlayers); + + /// PUT api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers + /// — set one sub-room's player cap, addressed by its ORDER INDEX, which is + /// the sub-room id the client and the rest of the wire use (the row's own + /// primary key is an internal detail). + /// + /// Clamped to the same 1..80 range as the room-level capacity: 0 would make + /// a sub-room unjoinable, and the ceiling matches what the room props + /// endpoint already enforces so the two settings can't disagree about what + /// is a legal value. + [HttpPut("rooms/{id:long}/subrooms/{subRoomId:int}/maxplayers")] + [HttpPost("rooms/{id:long}/subrooms/{subRoomId:int}/maxplayers")] + public async Task SetSubRoomMaxPlayers( + long id, int subRoomId, [FromBody] SetSubRoomMaxPlayersRequest body) + { + var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == id); + if (room is null) return NotFound(); + + var scene = await db.RoomScenes + .FirstOrDefaultAsync(s => s.RoomId == id && s.OrderIndex == subRoomId); + if (scene is null) return NotFound(); + + var previous = scene.MaxPlayers; + scene.MaxPlayers = Math.Clamp(body.MaxPlayers, 1, 80); + scene.DataModifiedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + await LogAsync("update_subroom_maxplayers", "room", id, + $"subRoom={subRoomId} maxPlayers {previous} -> {scene.MaxPlayers}"); + + return Ok(new + { + id = scene.Id.ToString(), + subRoomId = scene.OrderIndex, + name = scene.Name, + maxPlayers = scene.MaxPlayers, + }); + } + public sealed record AddRoleRequest(long PlayerId, int Role, bool Accepted = true); /// POST api/admin/v1/rooms/{id}/roles — grant a @@ -3325,132 +3400,12 @@ public class AdminController( // ── Helpers ────────────────────────────────────────────────────────── - private async Task DeletePlayerDataAsync(long playerId, long systemPlayerId) - { - var now = DateTime.UtcNow; - var deleted = 0; - var reassigned = 0; - - deleted += await db.Avatars.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.PlayerSettings.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.Relationships.Where(x => x.RequesterId == playerId || x.TargetId == playerId).ExecuteDeleteAsync(); - deleted += await db.CurrencyBalances.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.PlayerInventory.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.PlayerDevices.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.NotificationPrefs.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.CohortAssignments.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.PushTokens.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.PlatformIgnores.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.Cards.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.ObjectiveProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.GameRewardSelections.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.CouponRedemptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.LeaderboardStats.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.PlayerElo.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.RoyalePlayerProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.RoyaleMatchPlayers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - - deleted += await db.Messages.Where(x => x.SenderPlayerId == playerId || x.RecipientPlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.ChatMessages.Where(x => x.SenderPlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.ChatThreadMembers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - reassigned += await db.ChatThreads - .Where(x => x.CreatorPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId)); - - deleted += await db.Cheers.Where(x => x.FromPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.Reports.Where(x => x.ReporterPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.BugReports.Where(x => x.ReporterPlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.Subscriptions.Where(x => x.SubscriberPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync(); - - deleted += await db.ClubMemberships.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.ClubSubscriptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.ClubAnnouncementReads.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - reassigned += await db.Clubs - .Where(x => x.CreatorPlayerId == playerId) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.CreatorPlayerId, systemPlayerId) - .SetProperty(x => x.UpdatedAt, now)); - reassigned += await db.ClubAnnouncements - .Where(x => x.AuthorPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.AuthorPlayerId, systemPlayerId)); - - deleted += await db.RoomBookmarks.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.RoomVisits.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.RoomRoles.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - deleted += await db.RoomBans.Where(x => x.BannedPlayerId == playerId).ExecuteDeleteAsync(); - reassigned += await db.RoomBans - .Where(x => x.BannedByPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.BannedByPlayerId, systemPlayerId)); - reassigned += await db.RoomRoles - .Where(x => x.GrantedByPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.GrantedByPlayerId, systemPlayerId)); - reassigned += await db.Rooms - .Where(x => x.CreatorPlayerId == playerId) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.CreatorPlayerId, systemPlayerId) - .SetProperty(x => x.UpdatedAt, now)); - reassigned += await db.RoomDataBlobs - .Where(x => x.UploadedByPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.UploadedByPlayerId, systemPlayerId)); - reassigned += await db.RoomKeys - .Where(x => x.CreatorPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId)); - deleted += await db.RoomKeyPurchases.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - - deleted += await db.PlaylistInteractions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - reassigned += await db.Playlists - .Where(x => x.CreatorPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId)); - - deleted += await db.PlayerEventResponses - .Where(x => x.PlayerId == playerId - || db.PlayerEvents.Where(e => e.CreatorPlayerId == playerId).Select(e => e.Id).Contains(x.EventId)) - .ExecuteDeleteAsync(); - deleted += await db.PlayerEvents.Where(x => x.CreatorPlayerId == playerId).ExecuteDeleteAsync(); - - deleted += await db.PrivateInstanceInvitees.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); - var privateInstanceIds = await db.PrivateInstances - .Where(x => x.OwnerPlayerId == playerId) - .Select(x => x.Id) - .ToListAsync(); - if (privateInstanceIds.Count > 0) - { - deleted += await db.PrivateInstanceInvitees - .Where(x => privateInstanceIds.Contains(x.PrivateInstanceId)) - .ExecuteDeleteAsync(); - deleted += await db.PrivateInstances - .Where(x => privateInstanceIds.Contains(x.Id)) - .ExecuteDeleteAsync(); - } - - deleted += await db.GiftPackages - .Where(x => x.RecipientPlayerId == playerId) - .ExecuteDeleteAsync(); - if (playerId <= int.MaxValue) - { - reassigned += await db.GiftPackages - .Where(x => x.FromPlayerId == (int)playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.FromPlayerId, (int?)null)); - } - - reassigned += await db.Inventions - .Where(x => x.CreatorPlayerId == playerId) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.CreatorPlayerId, systemPlayerId) - .SetProperty(x => x.UpdatedAt, now)); - reassigned += await db.Photos - .Where(x => x.UploaderPlayerId == playerId) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.UploaderPlayerId, systemPlayerId) - .SetProperty(x => x.DeletedAt, now) - .SetProperty(x => x.IsPublic, false)); - - reassigned += await db.SignupCodes - .Where(x => x.RedeemedByPlayerId == playerId) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null)); - - return $"deleted_rows={deleted} reassigned_rows={reassigned}"; - } + /// Delegates to — + /// the cascade is shared with the 2023 client's self-service + /// DELETE account/me so both paths erase exactly the same set + /// of rows. + private Task DeletePlayerDataAsync(long playerId, long systemPlayerId) + => PlayerAccountPurge.PurgeAsync(db, playerId, systemPlayerId); private Task LogAsync(string action, string targetType, long targetId, string reason) { diff --git a/DorkNet.Server/Controllers/Auth/AuthController.cs b/DorkNet.Server/Controllers/Auth/AuthController.cs index cda2592..40f3955 100644 --- a/DorkNet.Server/Controllers/Auth/AuthController.cs +++ b/DorkNet.Server/Controllers/Auth/AuthController.cs @@ -325,6 +325,9 @@ public class AuthController( return Ok(results); } + // Sign-out / "forget this account" sends DELETE (LBNJFPOLCDL.txt:4190, + // verb 4); GET-only returned 405 and the forget-account promise rejected. + [HttpDelete("/cachedlogin/current")] [HttpGet("/cachedlogin/current")] public async Task CurrentCachedLogin() { diff --git a/DorkNet.Server/Controllers/Chat/ChatController.cs b/DorkNet.Server/Controllers/Chat/ChatController.cs index a426f35..7e1f5f3 100644 --- a/DorkNet.Server/Controllers/Chat/ChatController.cs +++ b/DorkNet.Server/Controllers/Chat/ChatController.cs @@ -30,11 +30,23 @@ public class ChatController( DorkNetDbContext db, NotificationService notifications) : ControllerBase { + // MGGNHLHPHOF on the 2023 wire: Success=0, InvalidArguments=1, + // ThreadNotFound=2, MembershipNotFound=3, PlayerAlreadyOnThread=4, + // CannotMessagePlayer=5, InvalidCharacters=6, RecentlyLeftThread=7, + // ThreadTooLarge=8. private const int ChatSuccess = 0; private const int ChatInvalidArguments = 1; + private const int ChatThreadNotFound = 2; private const int ChatMembershipNotFound = 3; private const int ChatPlayerAlreadyOnThread = 4; + /// Bit markers on ClubMembershipEntity.Permissions: + /// 128 = pending invite/request, 256 = banned. Both mean "not on the + /// roster" — same test ClubService.MembershipTypeFromPerms + /// applies. + private const int ClubPendingFlag = 128; + private const int ClubBannedFlag = 256; + private long Me => this.RequireCurrentPlayerId(); /// List the caller's chat threads, most-recently-active @@ -64,10 +76,34 @@ public class ChatController( .Distinct() .ToListAsync(); + // 3. Club chats for every club I'm actually on the roster of — + // club threads carry no ChatThreadMembers rows (the roster + // IS the membership), so without this the "Club chats" + // filter on the thread list is always empty. Only clubs + // whose channel has been used are listed, so browsing the + // inbox never materialises empty threads. + var myClubKeys = (await db.ClubMemberships + .Where(m => m.PlayerId == Me && + (m.Permissions & ClubPendingFlag) == 0 && + (m.Permissions & ClubBannedFlag) == 0) + .Select(m => m.ClubId) + .ToListAsync()) + .Select(ClubKey) + .ToList(); + var activeClubKeys = myClubKeys.Count == 0 + ? new List() + : await db.ChatMessages + .Where(c => myClubKeys.Contains(c.ThreadKey)) + .Select(c => c.ThreadKey) + .Distinct() + .ToListAsync(); + var keys = sentOrReceived .Concat(memberKeys) + .Concat(activeClubKeys) .Distinct() .Where(k => !k.StartsWith("group:") || memberKeys.Contains(k)) + .Where(k => ClubIdFromKey(k) == 0 || activeClubKeys.Contains(k)) .ToList(); if (keys.Count == 0) return Ok(Array.Empty()); @@ -83,6 +119,7 @@ public class ChatController( .Where(m => m.PlayerId == Me && keys.Contains(m.ThreadKey)) .ToDictionaryAsync(m => m.ThreadKey, m => m); var participants = await LoadParticipantsAsync(keys); + var favorites = await LoadFavoritesAsync(keys); return Ok(latest .OrderByDescending(c => c.SentAt) @@ -95,7 +132,8 @@ public class ChatController( meta, member, playerIds ?? new List(), - latestMessage: c); + latestMessage: c, + isFavorited: favorites.Contains(c.ThreadKey)); }) .ToList()); } @@ -113,17 +151,101 @@ public class ChatController( .OrderByDescending(c => c.SentAt) .Take(take) .ToListAsync(); - if (rows.Count == 0) return NotFound(); + + // An empty thread is NOT a missing thread. Starting a chat with someone + // goes POST thread/withmembers (which creates the thread) and then + // immediately GET thread/{id} — at that moment it has no messages yet. + // 404-ing on message count meant a brand-new conversation could never be + // opened: the client got "not found" for the thread it had just been + // handed. Only 404 when nothing actually records the thread. + if (rows.Count == 0) + { + var exists = await db.ChatThreads.AnyAsync(t => t.ThreadKey == key) + || await db.ChatThreadMembers.AnyAsync(m => m.ThreadKey == key); + if (!exists) return NotFound(); + } var meta = (await EnsureThreadRowsAsync(new[] { key }))[key]; var member = await db.ChatThreadMembers .FirstOrDefaultAsync(m => m.ThreadKey == key && m.PlayerId == Me); var participants = await LoadParticipantsAsync(new[] { key }); participants.TryGetValue(key, out var playerIds); + var favorites = await LoadFavoritesAsync(new[] { key }); return Ok(ToWireThread( meta, member, playerIds ?? new List(), - messages: rows.OrderBy(c => c.SentAt).ToList())); + messages: rows.OrderBy(c => c.SentAt).ToList(), + isFavorited: favorites.Contains(key))); + } + + /// GET thread/club/{clubId} — the 2023-03-21 club + /// chat tab's only entry point. Binary evidence: RecNet.Runtime + /// DLDKCILCKNA.txt method BOOOAPKCCJI(Int64), instr + /// 045 Move rcx, "thread/club/{0}", verb constant + /// Move rdx, 0 at instr 054 = HTTPMethods.Get, query fields + /// maxCount (instr 067, default 10) and mode (instr + /// 079, default 0). The declared return type is + /// FGLDKEJLAKB<List<JNMKLHDFPOJ>>, so the body + /// must be a JSON ARRAY of chat threads even though a club has + /// exactly one channel — hence the one-element array. + /// + /// The three-segment path matched no template before, so this 404'd + /// and club chat could not open at all. + /// + /// is accepted and ignored: the client + /// always sends 0 and its semantics are not resolvable from the + /// ISIL (no other value is ever passed). + [HttpGet("/thread/club/{clubId:long}")] + public async Task GetClubThreads(long clubId, + [FromQuery] int maxCount = 10, + [FromQuery] int mode = 0) + { + _ = mode; + var take = Math.Clamp(maxCount, 1, 200); + + var club = await db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId); + if (club is null) return Ok(Array.Empty()); + + // Only roster members see the club channel. Non-members get an + // empty array rather than a 403 — the client deserialises the + // body as a list before it ever looks at the status code. + var onRoster = await db.ClubMemberships.AnyAsync(m => + m.ClubId == clubId && m.PlayerId == Me && + (m.Permissions & ClubPendingFlag) == 0 && + (m.Permissions & ClubBannedFlag) == 0); + if (!onRoster) return Ok(Array.Empty()); + + var key = ClubKey(clubId); + var meta = await EnsureThreadRowAsync(key); + // Name the thread after the club the first time it materialises + // so the thread-list card has a label without a second fetch. + if (string.IsNullOrEmpty(meta.Name) && !string.IsNullOrEmpty(club.Name)) + { + meta.Name = club.Name.Length > 128 ? club.Name[..128] : club.Name; + meta.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + } + + var rows = await db.ChatMessages + .Where(c => c.ThreadKey == key) + .OrderByDescending(c => c.SentAt) + .Take(take) + .ToListAsync(); + var member = await db.ChatThreadMembers + .FirstOrDefaultAsync(m => m.ThreadKey == key && m.PlayerId == Me); + var participants = await LoadParticipantsAsync(new[] { key }); + participants.TryGetValue(key, out var playerIds); + var favorites = await LoadFavoritesAsync(new[] { key }); + + return Ok(new List> + { + ToWireThread( + meta, + member, + playerIds ?? new List(), + messages: rows.OrderBy(c => c.SentAt).ToList(), + isFavorited: favorites.Contains(key)), + }); } /// Recent messages for a thread, paginated by @@ -228,20 +350,8 @@ public class ChatController( // the sender's chat UI doesn't show its own outgoing message // until the next thread refresh. DM keys encode the two // participants directly; group chat threads fan out across the - // explicit membership table. - var recipients = new List(); - if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b)) - { - var other = a == Me ? b : a; - recipients.Add(other); - } - else if (key.StartsWith("group:")) - { - recipients = await db.ChatThreadMembers - .Where(m => m.ThreadKey == key && m.PlayerId != Me) - .Select(m => m.PlayerId) - .ToListAsync(); - } + // explicit membership table; club chats fan out over the roster. + var recipients = await ThreadRecipientsAsync(key); recipients.Add(Me); foreach (var rid in recipients) { @@ -334,11 +444,18 @@ public class ChatController( /// inbox. DMs can't be left — there are only two participants and /// removing one effectively ends the conversation; the watch /// surfaces a "delete chat" option for DMs instead. + // The 2023-03-21 client POSTs this; DELETE-only returned 405 and leaving a + // group chat was impossible on that build. + [HttpPost("/thread/{chatThreadId}/leave")] [HttpDelete("/thread/{chatThreadId}/leave")] public async Task Leave(string chatThreadId) { chatThreadId = await ResolveThreadKeyAsync(chatThreadId); - if (chatThreadId.StartsWith("dm:")) + // DMs can't be left, and neither can club chats — club channel + // membership is the club roster, so "leaving" is leaving the + // club. Deleting the row here would only discard the caller's + // last-read pointer. + if (chatThreadId.StartsWith("dm:") || ClubIdFromKey(chatThreadId) != 0) return Ok(ChatInvalidArguments); var removed = await db.ChatThreadMembers .Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me) @@ -346,24 +463,94 @@ public class ChatController( return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound); } - public sealed record RenameRequest(string Name); + /// Rename payload. A plain class with a parameterless ctor + /// (not a positional record) because + /// constructs it with + /// Activator.CreateInstance and then sets properties. + public sealed class RenameRequest + { + public string? Name { get; set; } + } /// Set the thread's display name. Permission: any member /// of the thread can rename. For DMs the watch typically nudges /// users to pick a nickname after first send; group-chat creators - /// usually set the name at creation time. + /// usually set the name at creation time. + /// + /// The 2023-03-21 client sends this as a form POST, not a JSON PUT: + /// RecNet.Runtime DLDKCILCKNA.txt method + /// BKNMPEEMNPB(Int64, String), instr 122 + /// Move rcx, "thread/{0}/rename" with verb constant + /// Move rdx, 2 at instr 131 (= HTTPMethods.Post) and a single + /// form field name added at instr 145 via + /// BNDIAONDFFF.AFGEDDANEKP. PUT-only + [FromBody] gave + /// that client a 405 (and a 415 even if it had used PUT). The + /// response is consumed by an Action<MGGNHLHPHOF> + /// (instr 162) — a BARE integer body, not a wrapper object. + /// PUT thread/message/{messageId}/moderate — a moderator + /// actions one message. + /// + /// Binary evidence: DLDKCILCKNA.txt method + /// FGLDKEJLAKB<MGGNHLHPHOF> PLBCHNGAEML(RecNet.ChatMessage, MOFGOGFEHNN) + /// (:8365); route literal at :8600 with verb constant + /// Move rdx, 3 (= PUT) at :8610 and chat host r8 = 8; the + /// single form field is moderationState (:8615). The response is the + /// same BARE MGGNHLHPHOF integer every other chat mutation returns. + /// + /// Gated to club/room moderators and admins — a plain thread member must + /// not be able to rewrite other people's messages. + [HttpPut("/thread/message/{messageId:long}/moderate")] + [HttpPost("/thread/message/{messageId:long}/moderate")] + public async Task ModerateMessage(long messageId) + { + var message = await db.ChatMessages.FirstOrDefaultAsync(m => m.Id == messageId); + if (message is null) return Ok(ChatThreadNotFound); + + var raw = Request.HasFormContentType + ? (await Request.ReadFormAsync())["moderationState"].FirstOrDefault() + : Request.Query["moderationState"].FirstOrDefault(); + if (!int.TryParse(raw, out var state)) return Ok(ChatInvalidArguments); + + // The message author can retract their own message; anyone else needs + // moderator standing on the club whose thread it belongs to. + var isAuthor = message.SenderPlayerId == Me; + if (!isAuthor) + { + var clubId = ClubIdFromKey(message.ThreadKey); + var allowed = false; + if (clubId != 0) + { + var membership = await db.ClubMemberships + .FirstOrDefaultAsync(m => m.ClubId == clubId && m.PlayerId == Me); + allowed = membership is not null && + ClubService.MembershipTypeFromPerms(membership.Permissions) + >= ClubService.MembershipTypeModerator; + } + if (!allowed) + { + allowed = await db.Players + .Where(p => p.Id == Me) + .Select(p => p.IsAdmin || p.IsCommunityTeam) + .FirstOrDefaultAsync(); + } + if (!allowed) return Ok(ChatMembershipNotFound); + } + + message.ModerationState = state; + await db.SaveChangesAsync(); + return Ok(ChatSuccess); + } + [HttpPut("/thread/{chatThreadId}/rename")] - public async Task Rename(string chatThreadId, [FromBody] RenameRequest body) + [HttpPost("/thread/{chatThreadId}/rename")] + public async Task Rename(string chatThreadId, + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameRequest body) { chatThreadId = await ResolveThreadKeyAsync(chatThreadId); - var name = (body.Name ?? string.Empty).Trim(); + var name = (body?.Name ?? string.Empty).Trim(); if (name.Length > 128) name = name[..128]; - var iAmMember = chatThreadId.StartsWith("dm:") - ? IsDmParticipant(chatThreadId, Me) - : await db.ChatThreadMembers - .AnyAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == Me); - if (!iAmMember) return Ok(ChatMembershipNotFound); + if (!await IsThreadMemberAsync(chatThreadId)) return Ok(ChatMembershipNotFound); var meta = await db.ChatThreads.FirstOrDefaultAsync(t => t.ThreadKey == chatThreadId); if (meta is null) @@ -381,6 +568,112 @@ public class ChatController( return Ok(ChatSuccess); } + /// Favourite payload — plain class for the same + /// reason as + /// . + public sealed class FavoriteRequest + { + public bool Favorite { get; set; } + } + + /// Star / unstar a chat thread for the caller. + /// + /// Binary evidence: RecNet.Runtime DLDKCILCKNA.txt method + /// DHKDPHCNLKD(Int64, Boolean) — instr 106 + /// Move rcx, "thread/{0}/favorite", verb constant + /// Move rdx, 3 at instr 115 (= HTTPMethods.Put), single form + /// field favorite (Boolean, boxed at instr 122, added at + /// instr 128). No route existed at all, so this 404'd. + /// + /// RESPONSE SHAPE: the continuation at instr 142 is + /// Func<GLIKNMPPEHL, MGGNHLHPHOF> — a client-side + /// PROJECTION, so the body is the wrapper object + /// GLIKNMPPEHL, NOT the bare chat-result int the method's + /// return type suggests. Its reader (OGCMIDEFOEE.txt instrs + /// 042/069) accepts exactly two keys, each in the usual three + /// casings: ChatResult and IsFavorited. + /// + /// The flag is per-(player, thread) and there is no column for it on + /// , so it is persisted in the + /// generic PlayerSettings table under + /// chat.favorite:<threadKey> and read back into the + /// thread DTO's IsFavorited by + /// . + [HttpPut("/thread/{chatThreadId}/favorite")] + [HttpPost("/thread/{chatThreadId}/favorite")] + public async Task Favorite(string chatThreadId, + [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] FavoriteRequest body) + { + var key = await ResolveThreadKeyAsync(chatThreadId); + if (!await db.ChatThreads.AnyAsync(t => t.ThreadKey == key) && + !await db.ChatMessages.AnyAsync(c => c.ThreadKey == key)) + { + return Ok(new Dictionary + { + ["chatResult"] = ChatThreadNotFound, + ["isFavorited"] = false, + }); + } + if (!await IsThreadMemberAsync(key)) + { + return Ok(new Dictionary + { + ["chatResult"] = ChatMembershipNotFound, + ["isFavorited"] = false, + }); + } + + var favorite = body?.Favorite ?? false; + var settingKey = FavoriteSettingKey(key); + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == settingKey); + if (favorite) + { + if (row is null) + { + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = Me, + Key = settingKey, + Value = "1", + }); + } + else + { + row.Value = "1"; + } + } + else if (row is not null) + { + db.PlayerSettings.Remove(row); + } + await db.SaveChangesAsync(); + + return Ok(new Dictionary + { + ["chatResult"] = ChatSuccess, + ["isFavorited"] = favorite, + }); + } + + /// Is the caller a participant of this thread? DM keys + /// carry both ids, group chats use the membership table, club chats + /// use the club roster. + private async Task IsThreadMemberAsync(string key) + { + if (key.StartsWith("dm:")) return IsDmParticipant(key, Me); + var clubId = ClubIdFromKey(key); + if (clubId != 0) + { + return await db.ClubMemberships.AnyAsync(m => + m.ClubId == clubId && m.PlayerId == Me && + (m.Permissions & ClubPendingFlag) == 0 && + (m.Permissions & ClubBannedFlag) == 0); + } + return await db.ChatThreadMembers + .AnyAsync(m => m.ThreadKey == key && m.PlayerId == Me); + } + public sealed record CreateGroupChatRequest(List MemberIds, string? Name); /// Create a group chat. Generates a fresh @@ -453,7 +746,10 @@ public class ChatController( .ToListAsync(); var meta = await EnsureThreadRowAsync(key); var participants = ids.Concat(new[] { Me }).Distinct().Select(x => (int)x).ToList(); - return Ok(ToWireThread(meta, null, participants, messages: rows.OrderBy(c => c.SentAt).ToList())); + var favorites = await LoadFavoritesAsync(new[] { key }); + return Ok(ToWireThread(meta, null, participants, + messages: rows.OrderBy(c => c.SentAt).ToList(), + isFavorited: favorites.Contains(key))); } /// Form-urlencoded variant of used by @@ -488,19 +784,7 @@ public class ChatController( // Notify every participant, sender INCLUDED — see SendMessageJson // above for the rationale; the 2020 watch's chat send path needs // the self-push to refresh its own UI. - var recipients = new List(); - if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b)) - { - var other = a == Me ? b : a; - if (other != Me) recipients.Add(other); - } - else if (key.StartsWith("group:")) - { - recipients = await db.ChatThreadMembers - .Where(m => m.ThreadKey == key && m.PlayerId != Me) - .Select(m => m.PlayerId) - .ToListAsync(); - } + var recipients = await ThreadRecipientsAsync(key); recipients.Add(Me); foreach (var rid in recipients) { @@ -510,9 +794,11 @@ public class ChatController( } var participants = await LoadParticipantsAsync(new[] { key }); participants.TryGetValue(key, out var playerIds); + var favorites = await LoadFavoritesAsync(new[] { key }); return Ok(new Dictionary { - ["chatThread"] = ToWireThread(thread, null, playerIds ?? new List(), latestMessage: entry), + ["chatThread"] = ToWireThread(thread, null, playerIds ?? new List(), + latestMessage: entry, isFavorited: favorites.Contains(key)), ["chatResult"] = ChatSuccess, }); } @@ -542,19 +828,7 @@ public class ChatController( db.ChatMessages.Add(entry); await db.SaveChangesAsync(); - var recipients = new List(); - if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b)) - { - var other = a == Me ? b : a; - if (other != Me) recipients.Add(other); - } - else if (key.StartsWith("group:")) - { - recipients = await db.ChatThreadMembers - .Where(m => m.ThreadKey == key && m.PlayerId != Me) - .Select(m => m.PlayerId) - .ToListAsync(); - } + var recipients = await ThreadRecipientsAsync(key); // Include the sender so the form-POST send path also refreshes // its own chat thread — same reason as the JSON send path above. recipients.Add(Me); @@ -758,15 +1032,109 @@ public class ChatController( result[group.Key] = group.Select(m => (int)m.PlayerId).Distinct().ToList(); } + // Club threads have no ChatThreadMembers rows — membership IS + // the club roster, so joining/leaving the club joins/leaves the + // chat automatically. + var clubIds = distinct + .Select(k => new { Key = k, ClubId = ClubIdFromKey(k) }) + .Where(x => x.ClubId != 0) + .ToList(); + if (clubIds.Count > 0) + { + var ids = clubIds.Select(x => x.ClubId).Distinct().ToList(); + var roster = await db.ClubMemberships + .Where(m => ids.Contains(m.ClubId) && + (m.Permissions & ClubPendingFlag) == 0 && + (m.Permissions & ClubBannedFlag) == 0) + .Select(m => new { m.ClubId, m.PlayerId }) + .ToListAsync(); + foreach (var x in clubIds) + { + result[x.Key] = roster + .Where(m => m.ClubId == x.ClubId) + .Select(m => (int)m.PlayerId) + .Distinct() + .ToList(); + } + } + return result; } + /// PlayerSettings key holding the caller's per-thread + /// favourite flag. The flag is per-(player, thread) and there is no + /// column for it on , so it + /// lives in the same generic per-player settings table the rest of + /// the server uses for exactly this kind of flag (see + /// RoomsController's playerdata and InventionsController). + private const string FavoritePrefix = "chat.favorite:"; + + private static string FavoriteSettingKey(string threadKey) => FavoritePrefix + threadKey; + + /// Thread keys the caller has starred, out of + /// . + private async Task> LoadFavoritesAsync(IEnumerable keys) + { + var distinct = keys.Distinct().ToList(); + if (distinct.Count == 0) return new HashSet(); + var settingKeys = distinct.Select(FavoriteSettingKey).ToList(); + var rows = await db.PlayerSettings + .AsNoTracking() + .Where(s => s.PlayerId == Me && settingKeys.Contains(s.Key) && s.Value == "1") + .Select(s => s.Key) + .ToListAsync(); + return rows.Select(k => k[FavoritePrefix.Length..]).ToHashSet(); + } + + /// Players to push a new message to. DM keys carry both + /// participants; group chats fan out over the membership table; + /// club chats fan out over the club roster. + private async Task> ThreadRecipientsAsync(string key) + { + if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b)) + { + var other = a == Me ? b : a; + return other == Me ? new List() : new List { other }; + } + if (key.StartsWith("group:")) + { + return await db.ChatThreadMembers + .Where(m => m.ThreadKey == key && m.PlayerId != Me) + .Select(m => m.PlayerId) + .ToListAsync(); + } + var clubId = ClubIdFromKey(key); + if (clubId != 0) + { + return await db.ClubMemberships + .Where(m => m.ClubId == clubId && m.PlayerId != Me && + (m.Permissions & ClubPendingFlag) == 0 && + (m.Permissions & ClubBannedFlag) == 0) + .Select(m => m.PlayerId) + .Distinct() + .ToListAsync(); + } + return new List(); + } + + /// Project a thread row onto the client's chat-thread DTO. + /// The 2023-03-21 reader (RecNet.Runtime EKEFFNBIOHJ.txt, instrs + /// 082/109/133/149/173/197/221/245/269) matches exactly nine keys, + /// each in PascalCase / camelCase / lowercase: + /// ChatThreadId, LastReadMessageId, Messages, LatestMessage, + /// PlayerIds, ChatThreadName, SnoozedUntil, IsFavorited, ClubId. + /// IsFavorited and ClubId did not exist on the 2020 + /// watch's DTO, so they were missing here; both are emitted now. + /// LatestMessage is emitted alongside Messages because + /// the thread-list card binds the preview off it even when the full + /// message array is present. private static Dictionary ToWireThread( ChatThreadEntity thread, ChatThreadMemberEntity? member, List playerIds, ChatMessageEntity? latestMessage = null, - List? messages = null) + List? messages = null, + bool isFavorited = false) { var dto = new Dictionary { @@ -775,20 +1143,46 @@ public class ChatController( ["lastReadMessageId"] = member?.LastReadMessageId ?? 0, ["snoozedUntil"] = member?.SnoozeUntil, ["playerIds"] = playerIds, + ["isFavorited"] = isFavorited, + ["clubId"] = ClubIdFromKey(thread.ThreadKey), }; + // latestMessage is ALWAYS present, null when the thread has nothing in + // it. The client's reader (EKEFFNBIOHJ) registers a slot for it, and a + // registered slot wants a value — null is one, an absent key is not. + // Omitting it is what broke starting a chat with someone: a thread + // created by thread/withmembers has no messages yet, so this key + // vanished from the one response where it was needed and the client + // rejected the payload it had just asked for. if (messages is not null) { dto["messages"] = messages.Select(m => ToWireMessage(m, thread.Id)).ToList(); + dto["latestMessage"] = messages.Count > 0 + ? ToWireMessage(messages[^1], thread.Id) + : null; } - else if (latestMessage is not null) + else { - dto["latestMessage"] = ToWireMessage(latestMessage, thread.Id); + dto["latestMessage"] = latestMessage is not null + ? ToWireMessage(latestMessage, thread.Id) + : null; } return dto; } + /// Canonical thread key for a club's chat channel. One + /// thread per club — the 2023 client's club chat tab is a single + /// channel, and GET thread/club/{id} returns it wrapped in a + /// one-element array. + private static string ClubKey(long clubId) => $"club:{clubId}"; + + /// Inverse of ; 0 for non-club + /// threads, which is what the client's ClubId field carries + /// for DMs and group chats. + private static long ClubIdFromKey(string key) => + key.StartsWith("club:") && long.TryParse(key.AsSpan(5), out var id) ? id : 0; + private static Dictionary ToWireMessage(ChatMessageEntity c, long chatThreadId) => new() { ["chatMessageId"] = c.Id, diff --git a/DorkNet.Server/Controllers/Clubs/ClubsController.cs b/DorkNet.Server/Controllers/Clubs/ClubsController.cs index 6b2e305..5f29214 100644 --- a/DorkNet.Server/Controllers/Clubs/ClubsController.cs +++ b/DorkNet.Server/Controllers/Clubs/ClubsController.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using System.Text.Json; using DorkNet.Server.Auth; +using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; @@ -16,28 +18,52 @@ namespace DorkNet.Server.Controllers.Clubs; /// URL surface (verb + template) verified against /// Cpp2IL_ISIL/.../JDJGIBLMFKK.txt: /// GET /club/home/me +/// PUT /club/home/me (2023, form clubId) +/// DELETE /club/home/me (2023) /// GET /club/mine/member /// GET /club/mine/created /// GET /club/categoryTags +/// DELETE /club/{clubId} (2023, disband) +/// GET /club/{clubId}/hasDisabledClubChat (2023, bare bool) +/// DELETE /club/{clubId}/mainimage (2023) +/// GET /club/{clubId}/members (2023, bare array) +/// GET /club/{clubId}/members/banned (2023, bare array) +/// GET /club/{clubId}/members/requests (2023, bare array) +/// GET /club/{clubId}/members/search (2023, paged envelope) +/// GET /club/{clubId}/members/requests/search (2023, paged envelope) +/// GET /announcements/mine (2023, flat array) +/// GET /announcements/subscription/mine (2023, flat array) /// GET /announcements/v2/mine/unread /// GET /announcements/v2/subscription/mine/unread /// POST /announcements/club/{clubId}/{announcementId}/read /// +/// Still unserved for want of schema (see the per-route notes in +/// docs/recroom-2023-client-api-complete.md): PUT +/// club/{id}/minlevel, PUT club/{id}/clubChatEnabled, PUT +/// club/{id}/permissions/{role} and DELETE +/// club/{id}/additionalimage/{slot} — each needs storage that does +/// not exist yet (ClubEntity.MinLevel, ClubEntity.ClubChatEnabled, a +/// per-role club-permission table, a club additional-image table). +/// /// Wire types (deserialiser JSON keys, per ISIL): -/// Club (PLILLKHMNDA): ClubId, Name, Description, -/// MainImageName, State, CreatorAccountId, Category, -/// Visibility, Joinability, AllowJuniors, MemberCount, -/// IsRRO, ClubhouseRoomId, ClubType -/// Announcement (NFEMLMAFFIP): AnnouncementId, +/// Club (2020 PLILLKHMNDA / 2023 FOIJDINBPFG): +/// ClubId, Name, Description, MainImageName, State, +/// CreatorAccountId, Category, Visibility, Joinability, +/// AllowJuniors, MemberCount, MinLevel, IsRRO, +/// ClubChatEnabled, ClubhouseRoomId, ClubType +/// Announcement (NFEMLMAFFIP / JDPPAFLFNBD): AnnouncementId, /// CreatorAccountId, ClubId, Title, Body, ImageName, /// CreatedAt, Meta -/// UnreadResponse (HPACLJHLHBG) — wraps per-club rows: -/// { clubId, LastReadAnnouncementId, announcements: [Announcement] } +/// UnreadResponse (EIKPFIDCKNE / NCHLBFPHFJE) — a LIST of +/// per-club rows { clubId, LastAnnouncementId, +/// LastReadAnnouncementId, announcements: [Announcement] } +/// ClubFeed (HPACLJHLHBG / FIAKMDGGIHH) — ONE such row, +/// not a list; see AnnouncementsForClub. /// CategoryTags plain List<String> (per /// JDJGIBLMFKK.GetPrimaryTags callback shape). /// [ApiController] -public class ClubsController(ClubService clubs) : ControllerBase +public class ClubsController(ClubService clubs, DorkNetDbContext db) : ControllerBase { private long Me => this.RequireCurrentPlayerId(); @@ -61,6 +87,50 @@ public class ClubsController(ClubService clubs) : ControllerBase return Ok(ToWireClub(home, memberCount)); } + /// PUT /club/home/me — "Set Home Clubhouse Room". The + /// 2023 client sends the target club in a form-urlencoded + /// clubId field (boxed Nullable<Int64> pushed through + /// BNDIAONDFFF.AFGEDDANEKP at IKMMOCKDKAF.txt:16923-16927) + /// and parses NO response DTO — the issuing method is + /// LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>), the status-only + /// promise type, so an empty 200 is the contract. Verb ordinal 3 (PUT) + /// is the HasValue branch of the same method at :16911; the null branch + /// is the DELETE below (:16838). + [HttpPut("/club/home/me")] + [Authorize] + public async Task HomeMeSet() + { + var pid = Me; + long clubId = 0; + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var key in new[] { "clubId", "ClubId" }) + if (long.TryParse(form[key].FirstOrDefault(), out var fromForm) && fromForm > 0) + { + clubId = fromForm; + break; + } + } + if (clubId == 0 && long.TryParse(Request.Query["clubId"].FirstOrDefault(), out var fromQuery)) + clubId = fromQuery; + if (clubId <= 0) return BadRequest(new { error = "missing_club_id" }); + if (!await clubs.SetHomeClubAsync(pid, clubId)) return NotFound(); + return Ok(); + } + + /// DELETE /club/home/me — "Remove Home Clubhouse Room". + /// No body, no response DTO (IKMMOCKDKAF.txt:16830-16841: route + /// literal, host 13, verb ordinal 4, error string "Failed to Remove Home + /// Clubhouse Room"). Idempotent so a double-tap is harmless. + [HttpDelete("/club/home/me")] + [Authorize] + public async Task HomeMeClear() + { + await clubs.ClearHomeClubAsync(Me); + return Ok(); + } + [HttpGet("/club/mine/member")] [Authorize] public async Task MineMember() @@ -154,7 +224,36 @@ public class ClubsController(ClubService clubs) : ControllerBase { var pid = Me; var rows = await clubs.UnreadDirectAsync(pid); - return Ok(GroupByClub(rows)); + return Ok(GroupByClub(rows, await LastReadByClubAsync(pid, rows.Select(a => a.ClubId)))); + } + + /// GET /announcements/mine — every announcement across the + /// clubs the caller is a member of, newest first. Distinct from the v2 + /// unread rollup above in BOTH shape and semantics: the issuing method is + /// FGLDKEJLAKB<List<JDPPAFLFNBD>> IOCFHCFLIMJ() + /// (IKMMOCKDKAF.txt:996), i.e. a FLAT array of Announcement objects + /// with no per-club envelope and no unread filter — the request literals + /// at :1075 carry the description "get all announcements for clubs I'm + /// in". + [HttpGet("/announcements/mine")] + [Authorize] + public async Task AnnouncementsMine() + { + var rows = await clubs.AnnouncementsForMemberClubsAsync(Me); + return Ok(rows.Select(ToWireAnnouncement).ToList()); + } + + /// GET /announcements/subscription/mine — same flat + /// List<JDPPAFLFNBD> shape as + /// (IIOMOLFOLPD at + /// IKMMOCKDKAF.txt:1083, route literal at :1162) but scoped to the + /// clubs the caller SUBSCRIBES to rather than belongs to. + [HttpGet("/announcements/subscription/mine")] + [Authorize] + public async Task AnnouncementsSubscriptionMine() + { + var rows = await clubs.AnnouncementsForSubscribedClubsAsync(Me); + return Ok(rows.Select(ToWireAnnouncement).ToList()); } [HttpGet("/announcements/v2/subscription/mine/unread")] @@ -163,7 +262,7 @@ public class ClubsController(ClubService clubs) : ControllerBase { var pid = Me; var rows = await clubs.UnreadSubscriptionAsync(pid); - return Ok(GroupByClub(rows)); + return Ok(GroupByClub(rows, await LastReadByClubAsync(pid, rows.Select(a => a.ClubId)))); } /// GET /subscription/mine/member — list of clubs the @@ -204,6 +303,17 @@ public class ClubsController(ClubService clubs) : ControllerBase return Ok(playerSubs + clubSubs); } + /// GET /subscription/details/{accountId} — the creator's + /// subscription card. Both clients read the same three keys: the 2020 watch + /// via LNIKPLKOBDK (exact-match lowercase accountId, + /// clubId, subscriberCount) and 2023 via DDFNNBDLBKF + /// (tri-cased AccountId/ClubId/SubscriberCount, DDFNNBDLBKF.txt:279-346) + /// — camelCase satisfies both, so the casing here is load-bearing for 2020 + /// and must not be Pascal-ised. + /// + /// clubId used to be missing entirely. The 2023 caller is literally + /// GetCreatorClubIdForSubscription, so the field defaulted to 0 and + /// profile→creator-club navigation went nowhere. [HttpGet("/subscription/details/{accountId:long}")] public async Task SubscriptionDetails(long accountId) { @@ -212,9 +322,14 @@ public class ClubsController(ClubService clubs) : ControllerBase var subscribedCount = await clubs.PlayerSubscribedCountAsync(accountId); var isSubscribed = viewerId is long viewer && await clubs.IsSubscribedToPlayerAsync(viewer, accountId); + // A creator's "creator club" is the club they own; oldest created wins + // when an account somehow owns several. + var created = await clubs.CreatedByAsync(accountId); + var clubId = created.Count > 0 ? created.Min(c => c.Id) : 0L; return Ok(new { accountId, + clubId, subscriberCount, subscribedCount, isSubscribed, @@ -249,6 +364,125 @@ public class ClubsController(ClubService clubs) : ControllerBase return Ok(ToWireClub(club, members)); } + /// DELETE /club/{id} — "Delete Club" (disband). Issuing + /// method is LDGADANDBIO MACDNDJNODA(Int64, String) — the string is + /// the club NAME used only for the client-side confirmation copy and is + /// never transmitted; the request is a bare DELETE with no body + /// (IKMMOCKDKAF.txt:17107-17120, verb ordinal 4, host 13, error + /// string "Failed to Delete Club"). LDGADANDBIO is the status-only + /// promise so an empty 200 is the whole contract. + /// + /// Owner-only, and a soft delete — see + /// . + [HttpDelete("/club/{clubId:long}")] + [Authorize] + public async Task ClubDelete(long clubId) + { + try + { + var disbanded = await clubs.DisbandAsync(clubId, Me); + if (disbanded is null) return NotFound(); + return Ok(); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + /// GET /club/{id}/hasDisabledClubChat — the gate the club + /// chat flow checks before it will even request the club's thread. The + /// issuing method is FGLDKEJLAKB<System.Boolean> OFOJAHBKMOJ(Int64) + /// (IKMMOCKDKAF.txt:25422, route + verb ordinal 0 at :25540-25547), + /// so the body is a BARE JSON boolean — not an object, not a wrapper. + /// + /// The answer is the negation of the Club wire field + /// ClubChatEnabled, which is now a real column set by + /// PUT club/{id}/clubChatEnabled. + [HttpGet("/club/{clubId:long}/hasDisabledClubChat")] + public async Task ClubHasDisabledChat(long clubId) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + return Content(club.ClubChatEnabled ? "false" : "true", "application/json"); + } + + /// PUT /club/{id}/clubChatEnabled — toggle the club's chat + /// channel (IKMMOCKDKAF.txt:25779). Form field + /// clubChatEnabled; responds with the refreshed details + /// envelope. + [HttpPut("/club/{clubId:long}/clubChatEnabled")] + [HttpPost("/club/{clubId:long}/clubChatEnabled")] + [Authorize] + public async Task SetClubChatEnabled(long clubId) + { + var value = await ReadBoolFieldAsync("clubChatEnabled", "ClubChatEnabled", "enabled"); + if (value is not bool enabled) return BadRequest(new { error = "missing_value" }); + try + { + var updated = await clubs.ModifyAsync(clubId, Me, c => c.ClubChatEnabled = enabled); + if (updated is null) return NotFound(); + } + catch (UnauthorizedAccessException) { return Forbid(); } + return await BuildDetailsResponseAsync(clubId); + } + + /// PUT /club/{id}/minlevel — minimum account level required + /// to join (IKMMOCKDKAF.txt:16561). Form field minLevel; + /// responds with the refreshed details envelope. + [HttpPut("/club/{clubId:long}/minlevel")] + [HttpPost("/club/{clubId:long}/minlevel")] + [Authorize] + public async Task SetClubMinLevel(long clubId) + { + var raw = await ReadFieldAsync("minLevel", "MinLevel", "level"); + if (!int.TryParse(raw, out var minLevel)) return BadRequest(new { error = "missing_value" }); + minLevel = Math.Clamp(minLevel, 0, 100); + try + { + var updated = await clubs.ModifyAsync(clubId, Me, c => c.MinLevel = minLevel); + if (updated is null) return NotFound(); + } + catch (UnauthorizedAccessException) { return Forbid(); } + return await BuildDetailsResponseAsync(clubId); + } + + /// First value present under any of the given names, from the + /// form, the query string, or a JSON body. Null means "not sent". + private async Task ReadFieldAsync(params string[] names) + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var n in names) + if (form.TryGetValue(n, out var v) && v.Count > 0) return v.ToString(); + } + foreach (var n in names) + if (Request.Query.TryGetValue(n, out var q) && q.Count > 0) return q.ToString(); + + if (!Request.HasFormContentType) + { + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await JsonDocument.ParseAsync(Request.Body); + Request.Body.Position = 0; + if (doc.RootElement.ValueKind == JsonValueKind.Object) + foreach (var n in names) + if (doc.RootElement.TryGetProperty(n, out var v) && + v.ValueKind != JsonValueKind.Null) return v.ToString(); + } + catch (JsonException) { /* no usable body */ } + } + return null; + } + + private async Task ReadBoolFieldAsync(params string[] names) + { + var raw = await ReadFieldAsync(names); + if (raw is null) return null; + if (bool.TryParse(raw, out var b)) return b; + return raw == "1" ? true : raw == "0" ? false : null; + } + /// GET /club/{id}/details — the central club page /// payload. Wire shape is PIHMJGCGNLP with required keys /// Club, CoownerPermissions, ModeratorPermissions, @@ -272,6 +506,20 @@ public class ClubsController(ClubService clubs) : ControllerBase { if (HttpMethods.IsPut(Request.Method) || HttpMethods.IsDelete(Request.Method)) { + // The client sends roomId in the form-urlencoded BODY, not the query + // string. Reading only the query bound null, and the PUT branch then + // wrote that null straight through — so "set clubhouse" silently + // CLEARED the clubhouse instead of setting it. + if (roomId is null && Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + foreach (var key in new[] { "roomId", "RoomId" }) + if (long.TryParse(form[key].FirstOrDefault(), out var fromForm) && fromForm > 0) + { + roomId = fromForm; + break; + } + } try { var updated = await clubs.ModifyAsync(clubId, Me, c => @@ -283,16 +531,62 @@ public class ClubsController(ClubService clubs) : ControllerBase return await BuildDetailsResponseAsync(clubId); } - /// GET /club/{id}/permissions/{role} — bare int of - /// the permission bitmask for a role on this club. Used by the - /// watch's role-permissions screen. Returns 0 for unknown roles so - /// the screen renders an empty checklist instead of erroring. + /// GET /club/{id}/permissions/{role} — the permission + /// record for one role on this club. This used to answer a bare int; both + /// clients model it as an OBJECT (2023 MMOCDPPONNG, 2020 + /// JHEEFBMODPG — identical layout: long ClubId, membership-type + /// enum, six bools), so see . Neither + /// client actually issues the GET (both only PUT this template), but the + /// shapes now agree. [HttpGet("/club/{clubId:long}/permissions/{role:int}")] public async Task ClubPermissions(long clubId, int role) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); - return Ok(PermissionsForRole(role)); + return Ok(await PermissionsForRoleAsync(clubId, role)); + } + + /// PUT /club/{id}/permissions/{role} — rewrite what one + /// role may do. Route and verb confirmed at + /// IKMMOCKDKAF.txt:17685 (route) and :17709 + /// (Move rdx, 3 = PUT); the six form booleans are + /// editDetails, approveMember, createEvent, + /// postAnnouncement, editPermissionSettings and + /// banUnban (IKMMOCKDKAF_NestedType_BOIMHOCCOEI.txt:173, + /// :189, :205, :221, :237, :253). + /// + /// Upserts one row per (club, role). Any field the client omits keeps the + /// value it currently resolves to, so a partial write cannot silently + /// revoke a permission. Gated on EditPermissionSettings for the caller's + /// own role, which is what the client's UI gate checks. + [HttpPut("/club/{clubId:long}/permissions/{role:int}")] + [HttpPost("/club/{clubId:long}/permissions/{role:int}")] + [Authorize] + public async Task SetClubPermissions(long clubId, int role) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid(); + + var current = await ResolveRolePermissionsAsync(clubId, role); + var row = await db.ClubRolePermissions + .FirstOrDefaultAsync(p => p.ClubId == clubId && p.MembershipType == role); + if (row is null) + { + row = new ClubRolePermissionEntity { ClubId = clubId, MembershipType = role }; + db.ClubRolePermissions.Add(row); + } + + row.EditDetails = await ReadBoolFieldAsync("editDetails", "EditDetails") ?? current.EditDetails; + row.ApproveMember = await ReadBoolFieldAsync("approveMember", "ApproveMember") ?? current.ApproveMember; + row.CreateEvent = await ReadBoolFieldAsync("createEvent", "CreateEvent") ?? current.CreateEvent; + row.PostAnnouncement = await ReadBoolFieldAsync("postAnnouncement", "PostAnnouncement") ?? current.PostAnnouncement; + row.EditPermissionSettings = await ReadBoolFieldAsync("editPermissionSettings", "EditPermissionSettings") ?? current.EditPermissionSettings; + row.BanUnban = await ReadBoolFieldAsync("banUnban", "BanUnban") ?? current.BanUnban; + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + return await BuildDetailsResponseAsync(clubId); } // ── Account / created lists ────────────────────────────────────── @@ -355,6 +649,17 @@ public class ClubsController(ClubService clubs) : ControllerBase public int? Joinability { get; set; } public bool? AllowJuniors { get; set; } public long? ClubhouseRoomId { get; set; } + + /// Repeated customTags form field sent by + /// club/{0}/modifydetails alongside visibility / joinability / + /// allowJuniors (IKMMOCKDKAF_NestedType_MAFIJOPDGHK.txt:139-185). + /// Null means "not supplied"; an empty list clears the club's tags. + /// Note the form path can only ever deliver null or a non-empty list — + /// FormOrJsonModelBinder.Convert collapses an all-blank repeated + /// field to null — so a client that clears every tag over + /// form-urlencoded leaves the existing set in place. Only the JSON path + /// can send an explicit []. + public List? CustomTags { get; set; } } /// POST /club/{id}/modify + /modifydetails — @@ -368,7 +673,7 @@ public class ClubsController(ClubService clubs) : ControllerBase [HttpPost("/club/{clubId:long}/modifydetails")] [HttpPut("/club/{clubId:long}/modifydetails")] [Authorize] - public async Task ClubModify(long clubId, [FromBody] ModifyClubRequest req) + public async Task ClubModify(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] ModifyClubRequest req) { try { @@ -383,6 +688,9 @@ public class ClubsController(ClubService clubs) : ControllerBase if (req.ClubhouseRoomId is long ch) c.ClubhouseRoomId = ch; }); if (updated is null) return NotFound(); + // Tags live in the club↔tag junction, not on the club row, so they + // are written after ModifyAsync has already vetted the caller. + if (req.CustomTags is not null) await ReplaceCustomTagsAsync(updated.Id, req.CustomTags); return await BuildDetailsResponseAsync(updated.Id); } catch (UnauthorizedAccessException) { return Forbid(); } @@ -396,7 +704,7 @@ public class ClubsController(ClubService clubs) : ControllerBase [HttpPost("/club/{clubId:long}/mainimage")] [HttpPut("/club/{clubId:long}/mainimage")] [Authorize] - public async Task ClubMainImage(long clubId, [FromBody] MainImageRequest req) + public async Task ClubMainImage(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MainImageRequest req) { try { @@ -408,20 +716,92 @@ public class ClubsController(ClubService clubs) : ControllerBase catch (UnauthorizedAccessException) { return Forbid(); } } - /// POST /club/{id}/additionalimage/{slot} — the - /// watch's gallery-image slot setter. The 2020 ClubEntity doesn't - /// have additional image slots persisted yet, so we just return - /// the current details unchanged (the gallery renders blank). - /// Hooked up so the request doesn't 404 — populated once a future - /// schema patch lands. + /// DELETE /club/{id}/mainimage — "Remove Club Image". + /// One method, BLEDOCHHHJM(Int64, String imageName), drives both + /// verbs: IKMMOCKDKAF.txt:16010 tests the captured imageName and + /// branches to verb ordinal 4 with no body when it is null (:16019-16024, + /// description "Remove Club Image") or verb 3 with the form field when it + /// is not (:16046-16050). Both branches go through + /// IBAKMFKEEDJ, so the response is the full LCLFBBPEMIH + /// details envelope, NOT a status-only 200. + /// + /// Registered as its own action rather than a third binding on + /// so the delete semantics are explicit — + /// aliasing an edit verb onto a destructive handler is the exact bug that + /// made PUT /announcements/club/{c}/{a} destroy posts. + [HttpDelete("/club/{clubId:long}/mainimage")] + [Authorize] + public async Task ClubMainImageDelete(long clubId) + { + try + { + var updated = await clubs.ModifyAsync(clubId, Me, c => c.ImageName = string.Empty); + if (updated is null) return NotFound(); + return await BuildDetailsResponseAsync(updated.Id); + } + catch (UnauthorizedAccessException) { return Forbid(); } + } + + /// POST/PUT /club/{id}/additionalimage/{slot} — the club + /// gallery's per-slot image setter (IKMMOCKDKAF.txt:16214). + /// + /// The slot is persisted as a row + /// and read back by the details envelope's AdditionalImages + /// (HIKCHBLAMLP = {ImageName, Slot}), so the gallery renders what + /// the creator set. An empty imageName clears the slot, same as the + /// DELETE twin. [HttpPost("/club/{clubId:long}/additionalimage/{slot:int}")] [HttpPut("/club/{clubId:long}/additionalimage/{slot:int}")] [Authorize] - public async Task ClubAdditionalImage(long clubId, int slot, [FromBody] MainImageRequest req) + public async Task ClubAdditionalImage(long clubId, int slot, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MainImageRequest req) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid(); + + var imageName = (req?.ImageName ?? string.Empty).Trim(); + var row = await db.ClubAdditionalImages + .FirstOrDefaultAsync(i => i.ClubId == clubId && i.Slot == slot); + if (imageName.Length == 0) + { + if (row is not null) db.ClubAdditionalImages.Remove(row); + } + else if (row is null) + { + db.ClubAdditionalImages.Add(new ClubAdditionalImageEntity + { + ClubId = clubId, + Slot = slot, + ImageName = imageName.Length > 256 ? imageName[..256] : imageName, + }); + } + else + { + row.ImageName = imageName.Length > 256 ? imageName[..256] : imageName; + row.UpdatedAt = DateTime.UtcNow; + } + await db.SaveChangesAsync(); + return await BuildDetailsResponseAsync(clubId); + } + + /// DELETE /club/{id}/additionalimage/{slot} — clear one + /// gallery slot. Same permission gate as the setter; idempotent, so + /// clearing an empty slot is a success. + [HttpDelete("/club/{clubId:long}/additionalimage/{slot:int}")] + [Authorize] + public async Task ClubAdditionalImageDelete(long clubId, int slot) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid(); + + var row = await db.ClubAdditionalImages + .FirstOrDefaultAsync(i => i.ClubId == clubId && i.Slot == slot); + if (row is not null) + { + db.ClubAdditionalImages.Remove(row); + await db.SaveChangesAsync(); + } return await BuildDetailsResponseAsync(clubId); } @@ -438,6 +818,332 @@ public class ClubsController(ClubService clubs) : ControllerBase return Ok(ToWireMembership(row)); } + // ── Member lists (roster / banned / requests, + their searches) ── + + /// GET /club/{id}/members — the club page's member roster. + /// Issuing method is + /// FGLDKEJLAKB<List<CADEIMCFIIG>> GFOBOIIGDJF(Int64, GNLOJEONFIG?, MJOOBDNCHBO?, Int32?, Int32?) + /// (IKMMOCKDKAF.txt:19444) so the body is a bare JSON ARRAY of the + /// same membership row already serves — no paged + /// envelope. Verb ordinal 0 (GET) at :19746, query keys verbatim from + /// :19753/:19777/:19809/:19822: membershipType, sortBy, + /// skip, take. + /// + /// membershipType is the MJOOBDNCHBO wire value, not the stored + /// perms int, so it is compared after + /// . With no filter the + /// roster is real members only — ban markers and pending rows have their + /// own endpoints below and must not leak into the member list. + [HttpGet("/club/{clubId:long}/members")] + public async Task ClubMembers( + [FromRoute] long clubId, + [FromQuery] int? membershipType, + [FromQuery] int? sortBy, + [FromQuery] int? skip, + [FromQuery] int? take) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + + var rows = await LoadMemberRowsAsync(clubId); + rows = membershipType is int wanted + ? rows.Where(r => r.MembershipType == wanted).ToList() + : rows.Where(r => r.MembershipType >= ClubService.MembershipTypeMember).ToList(); + + return Ok(Page(SortMembers(rows, sortBy ?? 0), skip ?? 0, take ?? 0) + .Select(r => ToWireMembership(r.Row)) + .ToList()); + } + + /// GET /club/{id}/members/banned — the banned list. + /// MBKDPCIOMDD(Int64, GNLOJEONFIG?, Int32?, Int32?) returns + /// List<ICPNBOOIDLI> (IKMMOCKDKAF.txt:18692): a bare + /// array whose rows carry only AccountId (int), ClubId + /// (long) and CreatedAt — there is no MembershipType field on + /// ICPNBOOIDLI (recnet-runtime-decomp/ICPNBOOIDLI.cs). GET at :18978, + /// query keys sortBy/skip/take at :18985/:19016/:19028. + /// + /// Rows are the perms=256 ban markers stamped by + /// . Moderator-gated: the ban list is staff-only + /// information. + [HttpGet("/club/{clubId:long}/members/banned")] + [Authorize] + public async Task ClubMembersBanned( + [FromRoute] long clubId, + [FromQuery] int? sortBy, + [FromQuery] int? skip, + [FromQuery] int? take) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid(); + + var rows = (await LoadMemberRowsAsync(clubId)) + .Where(r => (r.Row.Permissions & 256) != 0) + .ToList(); + + return Ok(Page(SortMembers(rows, sortBy ?? 0), skip ?? 0, take ?? 0) + .Select(r => ToWireBan(r.Row)) + .ToList()); + } + + /// GET /club/{id}/members/requests — pending join + /// requests / outstanding invites. + /// GBJMBMCGCDL(Int64, Int32?, Int32?) returns + /// List<AADIHDCMEDB> (IKMMOCKDKAF.txt:21086) — a bare + /// array, GET at :21325, only skip + take query keys + /// (:21338, :21350). Error copy "Failed to get pending club member + /// requests" at :21394 confirms the screen. + [HttpGet("/club/{clubId:long}/members/requests")] + [Authorize] + public async Task ClubMemberRequests( + [FromRoute] long clubId, + [FromQuery] int? skip, + [FromQuery] int? take) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid(); + + var rows = PendingRows(await LoadMemberRowsAsync(clubId)); + return Ok(Page(SortMembers(rows, 1), skip ?? 0, take ?? 0) + .Select(r => ToWireMemberRequest(r.Row)) + .ToList()); + } + + /// GET /club/{id}/members/search — the member-list search + /// box. PGMGPEKCNJP returns MBMNHFAPFCJ + /// (IKMMOCKDKAF.txt:18108), a PAGED envelope — + /// List<CADEIMCFIIG> + Int32 total + String continuation + /// token (recnet-runtime-decomp/MBMNHFAPFCJ.cs) — not the bare array the + /// unsearched roster returns. GET at :18475; query keys are DOT-PREFIXED + /// and must be read off rather than bound + /// as properties: parameters.name (:18484), + /// parameters.type (:18492), parameters.sortBy (:18521), + /// parameters.maxCount (:18554), plus an unprefixed + /// continuationToken (:18563). + /// + /// The envelope's own key names are the one thing the binary cannot give + /// up (they live in DataMember blobs in global-metadata.dat), so the + /// entity-named pair validated on the sibling club/search envelope + /// — Clubs/TotalClubs/ContinuationToken — is mirrored + /// here as Members/TotalMembers, with Results/ + /// TotalResults alongside as the alternate spelling. Json.NET + /// ignores whichever pair the DTO doesn't declare. + [HttpGet("/club/{clubId:long}/members/search")] + public async Task ClubMembersSearch(long clubId) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + + var name = QueryValue("parameters.name", "name"); + var type = QueryInt("parameters.type", "type", "membershipType"); + var sortBy = QueryInt("parameters.sortBy", "sortBy"); + var maxCount = QueryInt("parameters.maxCount", "maxCount", "take"); + var offset = QueryInt("continuationToken") ?? 0; + + var rows = await LoadMemberRowsAsync(clubId); + rows = type is int wanted + ? rows.Where(r => r.MembershipType == wanted).ToList() + : rows.Where(r => r.MembershipType >= ClubService.MembershipTypeMember).ToList(); + rows = FilterByName(rows, name); + + var ordered = SortMembers(rows, sortBy ?? 0).ToList(); + var (page, nextToken) = Slice(ordered, offset, maxCount); + return Ok(PagedEnvelope("Members", "TotalMembers", + page.Select(r => ToWireMembership(r.Row)).ToList(), ordered.Count, nextToken)); + } + + /// GET /club/{id}/members/requests/search — search inside + /// the pending-requests screen. EDANLDCJECM returns + /// EDFOCLNECPM (IKMMOCKDKAF.txt:21448): the paged envelope + /// over List<AADIHDCMEDB>. GET at :21812; dot-prefixed query + /// keys parameters.name (:21821), parameters.sortBy + /// (:21829, the NAHBJCGNJKA enum — Default=0, RequestDate_Asc=1, + /// RequestDate_Desc=2, Username_Asc=3, Username_Desc=4), + /// parameters.maxCount (:21862), parameters.status (:21867, + /// MDFFODMAIGJ — Invited=0, Requested=1, Denied=2) and unprefixed + /// continuationToken (:21896). + /// + /// RequestDate_Asc/Desc index the same JoinedAt column JoinDate_Asc/Desc + /// do on the roster sort, so the two enums share + /// . The status filter can only ever + /// match Requested — see . + [HttpGet("/club/{clubId:long}/members/requests/search")] + [Authorize] + public async Task ClubMemberRequestsSearch(long clubId) + { + var club = await clubs.GetByIdAsync(clubId); + if (club is null) return NotFound(); + if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid(); + + var name = QueryValue("parameters.name", "name"); + var sortBy = QueryInt("parameters.sortBy", "sortBy"); + var maxCount = QueryInt("parameters.maxCount", "maxCount", "take"); + var status = QueryInt("parameters.status", "status"); + var offset = QueryInt("continuationToken") ?? 0; + + var rows = FilterByName(PendingRows(await LoadMemberRowsAsync(clubId)), name); + if (status is int wantedStatus) + rows = rows.Where(r => RequestStatusFor(r.Row) == wantedStatus).ToList(); + + var ordered = SortMembers(rows, sortBy ?? 0).ToList(); + var (page, nextToken) = Slice(ordered, offset, maxCount); + return Ok(PagedEnvelope("Requests", "TotalRequests", + page.Select(r => ToWireMemberRequest(r.Row)).ToList(), ordered.Count, nextToken)); + } + + /// A membership row paired with the account's username, which + /// every list endpoint above needs for the Username_Asc/Desc sorts and + /// the parameters.name filter. Loaded once per request rather than + /// per row. + private sealed record MemberRow(ClubMembershipEntity Row, string Username) + { + public int MembershipType => ClubService.MembershipTypeFromPerms(Row.Permissions); + } + + private async Task> LoadMemberRowsAsync(long clubId) + { + var rows = await db.ClubMemberships.AsNoTracking() + .Where(m => m.ClubId == clubId) + .ToListAsync(); + if (rows.Count == 0) return new List(); + + var ids = rows.Select(r => r.PlayerId).Distinct().ToList(); + var names = await db.Players.AsNoTracking() + .Where(p => ids.Contains(p.Id)) + .Select(p => new { p.Id, p.Username, p.DisplayName }) + .ToListAsync(); + var byId = names.ToDictionary( + n => n.Id, + n => string.IsNullOrWhiteSpace(n.Username) ? n.DisplayName : n.Username); + + return rows + .Select(r => new MemberRow(r, byId.GetValueOrDefault(r.PlayerId, string.Empty))) + .ToList(); + } + + /// Rows carrying the pending marker (128) and NOT the ban marker + /// — the join-request / invite queue. + private static List PendingRows(List rows) => rows + .Where(r => (r.Row.Permissions & 128) != 0 && (r.Row.Permissions & 256) == 0) + .ToList(); + + private static List FilterByName(List rows, string? name) + { + if (string.IsNullOrWhiteSpace(name)) return rows; + var needle = name.Trim(); + return rows + .Where(r => r.Username.Contains(needle, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + /// Shared ordering for GNLOJEONFIG (roster/banned) and + /// NAHBJCGNJKA (requests) — both enums are Default=0, date asc=1, + /// date desc=2, Username_Asc=3, Username_Desc=4 over the same JoinedAt + /// column (recnet-runtime-decomp/GNLOJEONFIG.cs, NAHBJCGNJKA.cs). + /// Default puts the most privileged roles first, which is the order the + /// club page renders its roster in. + private static IEnumerable SortMembers(IEnumerable rows, int sortBy) => sortBy switch + { + 1 => rows.OrderBy(r => r.Row.JoinedAt).ThenBy(r => r.Row.Id), + 2 => rows.OrderByDescending(r => r.Row.JoinedAt).ThenByDescending(r => r.Row.Id), + 3 => rows.OrderBy(r => r.Username, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Row.Id), + 4 => rows.OrderByDescending(r => r.Username, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Row.Id), + _ => rows.OrderByDescending(r => r.MembershipType) + .ThenBy(r => r.Row.JoinedAt) + .ThenBy(r => r.Row.Id), + }; + + /// skip/take paging for the three bare-array lists. take=0 means + /// "unspecified" (the client omits the param), so it falls back to a + /// 100-row page rather than returning nothing. + private static List Page(IEnumerable rows, int skip, int take) => rows + .Skip(Math.Max(skip, 0)) + .Take(take <= 0 ? 100 : Math.Clamp(take, 1, 500)) + .ToList(); + + /// Continuation-token paging for the two search envelopes. The + /// token is opaque to the client (a String on the DTO), so it carries the + /// next row offset; an empty string means "no more pages", which is what + /// the sibling club/search envelope already returns. + private static (List Page, string NextToken) Slice( + List ordered, int offset, int? maxCount) + { + var start = Math.Clamp(offset, 0, ordered.Count); + var size = maxCount is int m && m > 0 ? Math.Clamp(m, 1, 500) : 30; + var page = ordered.Skip(start).Take(size).ToList(); + var next = start + page.Count; + return (page, next < ordered.Count ? next.ToString() : string.Empty); + } + + private static Dictionary PagedEnvelope( + string listKey, string totalKey, List rows, int total, string continuationToken) => + new() + { + [listKey] = rows, + ["Results"] = rows, + [totalKey] = total, + ["TotalResults"] = total, + ["ContinuationToken"] = continuationToken, + }; + + /// First non-empty value across a set of query-string aliases. + /// The 2023 client prefixes the search DTO's fields with + /// parameters., which ASP.NET cannot bind to a plain property, so + /// these are read straight off the query collection. + private string? QueryValue(params string[] keys) + { + foreach (var key in keys) + { + var value = Request.Query[key].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(value)) return value; + } + return null; + } + + private int? QueryInt(params string[] keys) => + int.TryParse(QueryValue(keys), out var parsed) ? parsed : null; + + /// ICPNBOOIDLI — the banned-list row. Three fields only + /// (recnet-runtime-decomp/ICPNBOOIDLI.cs): int AccountId, long ClubId, + /// DateTime. CreatedAt is the membership row's JoinedAt, which + /// leaves at the original join time when it + /// stamps the ban marker onto an existing row. + private static object ToWireBan(ClubMembershipEntity row) => new + { + AccountId = (int)row.PlayerId, + ClubId = row.ClubId, + CreatedAt = row.JoinedAt, + }; + + /// AADIHDCMEDB — the pending-request row. Field list from + /// recnet-runtime-decomp/AADIHDCMEDB.cs: long id, int AccountId, int? + /// inviter account id, long ClubId, MJOOBDNCHBO MembershipType, + /// MDFFODMAIGJ status, DateTime. + /// + /// Two fields the current perms model genuinely cannot fill, rather than + /// invent: the inviter is not recorded anywhere (invites and requests + /// both collapse to the single 128 marker on the target's own row) so it + /// is emitted as null — the DTO declares it nullable — and the status is + /// always Requested for the same reason. Nothing in the accept/deny flow + /// reads either field: those calls key off accountId. + private static object ToWireMemberRequest(ClubMembershipEntity row) => new + { + Id = row.Id, + RequestId = row.Id, + AccountId = (int)row.PlayerId, + InviterAccountId = (int?)null, + ClubId = row.ClubId, + MembershipType = ClubService.MembershipTypeFromPerms(row.Permissions), + Status = RequestStatusFor(row), + CreatedAt = row.JoinedAt, + }; + + /// MDFFODMAIGJ: Invited=0, Requested=1, Denied=2. Only Requested + /// is reachable — see . + private static int RequestStatusFor(ClubMembershipEntity row) => 1; + // ── Member mutations (void / IPromise) ─────────────────────────── /// POST /club/{id}/members/requesttojoin — caller @@ -450,10 +1156,15 @@ public class ClubsController(ClubService clubs) : ControllerBase { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); + // Joinability enum, confirmed from the 2023 dump (enum FEHIHCMDOLN): + // Open = 0, InviteOnly = 1, AskToJoin = 2. + // This used to treat 1 as request-to-join and 2 as invite-only, i.e. + // exactly backwards: "Ask to join" clubs rejected every request while + // invite-only clubs quietly accepted them. var perms = club.Joinability switch { 0 => 0, // Open: instant member - 1 => 128, // RequestToJoin: pending + 2 => 128, // AskToJoin: pending approval _ => -1, // InviteOnly: rejected }; if (perms == -1) return Forbid(); @@ -503,7 +1214,7 @@ public class ClubsController(ClubService clubs) : ControllerBase [HttpPost("/club/{clubId:long}/members/invite")] [HttpPut("/club/{clubId:long}/members/invite")] [Authorize] - public async Task MemberInvite(long clubId, [FromBody] MemberInviteRequest req) + public async Task MemberInvite(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberInviteRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var target = req.PlayerId ?? req.AccountId ?? 0; @@ -524,7 +1235,7 @@ public class ClubsController(ClubService clubs) : ControllerBase [HttpPost("/club/{clubId:long}/members/invitemembers")] [Authorize] - public async Task MemberInviteMembers(long clubId, [FromBody] InviteMembersRequest req) + public async Task MemberInviteMembers(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] InviteMembersRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var ids = (req.PlayerIds ?? req.AccountIds ?? new List()).Distinct().ToList(); @@ -568,7 +1279,7 @@ public class ClubsController(ClubService clubs) : ControllerBase /// target's row from Pending to Member. [HttpPost("/club/{clubId:long}/members/acceptrequest")] [Authorize] - public async Task MemberAcceptRequest(long clubId, [FromBody] MemberTargetRequest req) + public async Task MemberAcceptRequest(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var target = req.PlayerId ?? req.AccountId ?? 0; @@ -588,7 +1299,7 @@ public class ClubsController(ClubService clubs) : ControllerBase [HttpPost("/club/{clubId:long}/members/acceptrequests")] [Authorize] - public async Task MemberAcceptRequests(long clubId, [FromBody] BulkTargetRequest req) + public async Task MemberAcceptRequests(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] BulkTargetRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var ids = (req.PlayerIds ?? req.AccountIds ?? new List()).Distinct().ToList(); @@ -603,7 +1314,7 @@ public class ClubsController(ClubService clubs) : ControllerBase [HttpPost("/club/{clubId:long}/members/denyrequest")] [Authorize] - public async Task MemberDenyRequest(long clubId, [FromBody] MemberTargetRequest req) + public async Task MemberDenyRequest(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var target = req.PlayerId ?? req.AccountId ?? 0; @@ -614,7 +1325,7 @@ public class ClubsController(ClubService clubs) : ControllerBase [HttpPost("/club/{clubId:long}/members/denyrequests")] [Authorize] - public async Task MemberDenyRequests(long clubId, [FromBody] BulkTargetRequest req) + public async Task MemberDenyRequests(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] BulkTargetRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var ids = (req.PlayerIds ?? req.AccountIds ?? new List()).Distinct().ToList(); @@ -625,7 +1336,7 @@ public class ClubsController(ClubService clubs) : ControllerBase [HttpPost("/club/{clubId:long}/members/remove")] [Authorize] - public async Task MemberRemove(long clubId, [FromBody] MemberTargetRequest req) + public async Task MemberRemove(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); @@ -643,7 +1354,7 @@ public class ClubsController(ClubService clubs) : ControllerBase /// request-to-join flow can refuse them later. [HttpPost("/club/{clubId:long}/members/ban")] [Authorize] - public async Task MemberBan(long clubId, [FromBody] MemberTargetRequest req) + public async Task MemberBan(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); @@ -658,7 +1369,7 @@ public class ClubsController(ClubService clubs) : ControllerBase [HttpPost("/club/{clubId:long}/members/unban")] [Authorize] - public async Task MemberUnban(long clubId, [FromBody] MemberTargetRequest req) + public async Task MemberUnban(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req) { if (!await clubs.CanManageAsync(clubId, Me)) return Forbid(); var target = req.PlayerId ?? req.AccountId ?? 0; @@ -674,7 +1385,7 @@ public class ClubsController(ClubService clubs) : ControllerBase [HttpPost("/club/{clubId:long}/members/changetype")] [HttpPut("/club/{clubId:long}/members/changetype")] [Authorize] - public async Task MemberChangeType(long clubId, [FromBody] MemberTargetRequest req) + public async Task MemberChangeType(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req) { var club = await clubs.GetByIdAsync(clubId); if (club is null) return NotFound(); @@ -690,35 +1401,64 @@ public class ClubsController(ClubService clubs) : ControllerBase // ── members/bulk + clubreporting ───────────────────────────────── - /// GET /members/bulk?clubId=N&playerIds=A&playerIds=B - /// — bulk membership lookup. Wire shape is - /// List<JHMMGLNJHIB> with each entry carrying - /// AccountId + MembershipType. + /// GET/POST /members/bulk?id=A&id=B — bulk membership + /// lookup for nameplate club badges. Response rows are + /// FCKGOFHNDNJ: AccountId + MembershipType + /// (reader FMKALNIKMKF.txt). + /// + /// Two things the original handler got wrong, both visible at + /// IKMMOCKDKAF.txt:20976-21005: + /// * the account ids ride in repeated id params (:20997), not + /// playerIds; + /// * clubId is NEVER transmitted — it is only the client's local + /// cache key — so hard-requiring it 400'd every single call. + /// The verb is picked at runtime by a cmov (:20984-20985, + /// cmp rdi,100 / cmovge edx,eax with eax=2): GET under 100 ids, + /// POST at 100+. Both are registered. + /// + /// With no club scope the answer is each account's most privileged + /// membership across all clubs, which is what the badge renders anyway; + /// an explicit clubId (2020 watch, admin tools) still narrows it. + /// [HttpGet("/members/bulk")] [HttpPost("/members/bulk")] public async Task MembersBulk() { - var form = Request.HasFormContentType ? Request.Form : null; - if (!long.TryParse(Request.Query["clubId"].ToString(), out var clubId) - && (form is null || !long.TryParse(form["clubId"].ToString(), out clubId))) - return BadRequest(new { error = "missing_club_id" }); + var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null; - var idsRaw = form is null - ? Request.Query["playerIds"].AsEnumerable() - : Request.Query["playerIds"].Concat(form["playerIds"]); - var ids = idsRaw + IEnumerable Values(string key) => form is null + ? Request.Query[key].AsEnumerable() + : Request.Query[key].Concat(form[key]); + + var ids = new[] { "id", "playerIds", "accountIds" } + .SelectMany(Values) .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Select(s => long.TryParse(s, out var n) ? n : 0) .Where(n => n != 0) + .Distinct() .ToList(); if (ids.Count == 0) return Ok(Array.Empty()); - var rows = await clubs.MembershipsBulkAsync(clubId, ids); - return Ok(rows.Select(m => new - { - AccountId = (int)m.PlayerId, - MembershipType = ClubService.MembershipTypeFromPerms(m.Permissions), - })); + long? clubId = long.TryParse(Request.Query["clubId"].ToString(), out var q) ? q + : form is not null && long.TryParse(form["clubId"].ToString(), out var f) ? f + : null; + + var rows = clubId is long scope + ? await clubs.MembershipsBulkAsync(scope, ids) + : await db.ClubMemberships.AsNoTracking() + .Where(m => ids.Contains(m.PlayerId)) + .ToListAsync(); + + // One row per account: highest membership type wins (Creator > CoOwner > + // Moderator > Member), and ban/pending markers lose to a real role. + return Ok(rows + .GroupBy(m => m.PlayerId) + .Select(g => new + { + AccountId = (int)g.Key, + MembershipType = g.Max(m => ClubService.MembershipTypeFromPerms(m.Permissions)), + }) + .ToList()); } // NOTE: POST /api/clubreporting/v1/report is served by @@ -730,15 +1470,25 @@ public class ClubsController(ClubService clubs) : ControllerBase // ── Announcements (per-club feed + per-announcement) ───────────── - /// GET /announcements/club/{clubId} — full - /// announcement feed for the club. Wire shape mirrors the unread - /// rollup (HPACLJHLHBG envelope) but holds every visible - /// announcement, newest first. + /// GET /announcements/club/{clubId} — full announcement + /// feed for one club, newest first. + /// + /// This returns a SINGLE envelope object, never an array: 2023 reads it as + /// FGLDKEJLAKB<FIAKMDGGIHH> + /// (IKMMOCKDKAF.txt:1170, route at :1452) and the 2020 watch as + /// IPromise<HPACLJHLHBG> (JDJGIBLMFKK.txt:1012,1167). + /// Handing either of them the per-club LIST that the unread rollup uses + /// failed the reader, so the club announcement board never rendered — and + /// a club with no announcements produced [], not an object at + /// all. [HttpGet("/announcements/club/{clubId:long}")] public async Task AnnouncementsForClub(long clubId) { var rows = await clubs.AnnouncementsForClubAsync(clubId); - return Ok(GroupByClub(rows)); + var lastRead = ControllerBaseExtensions.CurrentPlayerId(User) is long pid + ? await LastReadByClubAsync(pid, new[] { clubId }) + : new Dictionary(); + return Ok(AnnouncementEnvelope(clubId, rows, lastRead.GetValueOrDefault(clubId))); } /// GET /announcements/club/{clubId}/{aid} — single @@ -759,7 +1509,6 @@ public class ClubsController(ClubService clubs) : ControllerBase /// double-fires are safe. [HttpDelete("/announcements/club/{clubId:long}/{announcementId:long}")] [HttpPost("/announcements/club/{clubId:long}/{announcementId:long}")] - [HttpPut("/announcements/club/{clubId:long}/{announcementId:long}")] [Authorize] public async Task AnnouncementDelete(long clubId, long announcementId) { @@ -767,6 +1516,72 @@ public class ClubsController(ClubService clubs) : ControllerBase return Ok(); } + /// PUT /announcements/club/{clubId}/{announcementId} — EDIT + /// an announcement. + /// + /// PUT used to be a third binding on , so + /// editing an announcement silently DELETED it — the edit form's save + /// button destroyed the post it was editing. It is its own handler now. + [HttpPut("/announcements/club/{clubId:long}/{announcementId:long}")] + [Authorize] + public async Task AnnouncementEdit(long clubId, long announcementId) + { + var (title, body, imageName) = await ReadAnnouncementFieldsAsync(); + if (!await clubs.UpdateAnnouncementAsync(clubId, announcementId, Me, title, body, imageName)) + return Forbid(); + return Ok(); + } + + /// POST /announcements/club/{clubId} — CREATE an + /// announcement. The client posts the fields to the COLLECTION url and + /// reads the response as a bare Int64 (the new announcement id). No POST + /// was registered at that template, so posting a club announcement 404'd. + [HttpPost("/announcements/club/{clubId:long}")] + [Authorize] + public async Task AnnouncementCreate(long clubId) + { + var (title, body, imageName) = await ReadAnnouncementFieldsAsync(); + var id = await clubs.CreateAnnouncementAsync( + clubId, Me, title ?? string.Empty, body ?? string.Empty, imageName ?? string.Empty); + if (id is null) return Forbid(); + return Content(id.Value.ToString(), "application/json"); + } + + /// Announcement fields as the client sends them: form-urlencoded, + /// with a JSON body accepted as a fallback. Null means "not supplied", so + /// an edit can leave a field alone. + private async Task<(string? Title, string? Body, string? ImageName)> ReadAnnouncementFieldsAsync() + { + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + string? F(params string[] keys) + { + foreach (var k in keys) + if (form.TryGetValue(k, out var v) && v.Count > 0) return v.ToString(); + return null; + } + return (F("title", "Title"), F("body", "Body"), F("imageName", "ImageName")); + } + + try + { + Request.EnableBuffering(); + Request.Body.Position = 0; + using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body); + Request.Body.Position = 0; + string? P(params string[] keys) + { + foreach (var k in keys) + if (doc.RootElement.TryGetProperty(k, out var v) && + v.ValueKind == System.Text.Json.JsonValueKind.String) return v.GetString(); + return null; + } + return (P("title", "Title"), P("body", "Body"), P("imageName", "ImageName")); + } + catch (System.Text.Json.JsonException) { return (null, null, null); } + } + // ── Wire-shape mappers ────────────────────────────────────────── /// @@ -793,37 +1608,136 @@ public class ClubsController(ClubService clubs) : ControllerBase } return Ok(new { - // PIHMJGCGNLP deserializer at PIHMJGCGNLP.txt:143-194 reads - // 7 fields off the response dict, not the 5 the parser-keys - // doc lists. Missing any throws KeyNotFoundException — - // CustomTags (List) and AdditionalImages - // (List) need to be present even if empty. + // All seven keys are mandatory — 2020's PIHMJGCGNLP deserializer + // (PIHMJGCGNLP.txt:143-194) throws KeyNotFoundException on a missing + // one, and 2023's LCLFBBPEMIH registers exactly these names + // (JAHJGFHFKIB.txt:523-678). AdditionalImages stays empty until the + // gallery slots get a table; see ClubAdditionalImage. Club = ToWireClub(club, memberCount), - CustomTags = Array.Empty(), - AdditionalImages = Array.Empty(), - CoownerPermissions = PermissionsForRole(ClubService.MembershipTypeCoOwner), - ModeratorPermissions = PermissionsForRole(ClubService.MembershipTypeModerator), - MemberPermissions = PermissionsForRole(ClubService.MembershipTypeMember), + CustomTags = await CustomTagsAsync(clubId), + AdditionalImages = await db.ClubAdditionalImages + .Where(i => i.ClubId == clubId) + .OrderBy(i => i.Slot) + .Select(i => new { i.ImageName, i.Slot }) + .ToListAsync(), + CoownerPermissions = await PermissionsForRoleAsync(clubId, ClubService.MembershipTypeCoOwner), + ModeratorPermissions = await PermissionsForRoleAsync(clubId, ClubService.MembershipTypeModerator), + MemberPermissions = await PermissionsForRoleAsync(clubId, ClubService.MembershipTypeMember), MyMembershipType = myType, }); } - /// - /// Bitmask of capabilities a given role has on a club. The bits - /// are arbitrary (the wire enum JHEEFBMODPG is opaque in - /// the readable dump) but stay consistent so the watch's - /// role-permissions screen renders the same checklist on every - /// refresh. Owner / Coowner can manage all; Moderator can manage - /// content; Member can read. - /// - private static int PermissionsForRole(int membershipType) => membershipType switch + /// The club's player-authored tags, for the envelope's + /// CustomTags (List<String> on both clients). These used + /// to be a hardcoded empty array; they are stored in the existing club↔tag + /// junction so the value the client PUT actually comes back. + private Task> CustomTagsAsync(long clubId) => + (from a in db.ClubCategoryAssignments + join t in db.ClubCategoryTags on a.CategoryTagId equals t.Id + where a.ClubId == clubId + orderby t.OrderIndex, t.Name + select t.Name).ToListAsync(); + + /// Replace a club's tag assignments wholesale — the client always + /// PUTs the complete customTags set, never a delta. + private async Task ReplaceCustomTagsAsync(long clubId, List tags) { - ClubService.MembershipTypeOwner => 0x7FFF, // everything - ClubService.MembershipTypeCoOwner => 0x7FFE, // all except disband - ClubService.MembershipTypeModerator => 0x00FF, // moderation only - ClubService.MembershipTypeMember => 0x0007, // post + read + react - _ => 0x0001, // read-only - }; + var wanted = tags + .Select(t => (t ?? string.Empty).Trim()) + .Where(t => t.Length > 0 && t.Length <= 64) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var stale = await db.ClubCategoryAssignments + .Where(a => a.ClubId == clubId) + .ToListAsync(); + db.ClubCategoryAssignments.RemoveRange(stale); + + foreach (var name in wanted) + { + var lower = name.ToLower(); + var tag = await db.ClubCategoryTags.FirstOrDefaultAsync(t => t.Name.ToLower() == lower); + if (tag is null) + { + // A player-authored tag is not part of the admin-curated + // category list, so it lands Active=false: assignments still + // resolve by id, but /club/categoryTags keeps returning only + // the curated set the browse tab is built from. + tag = new ClubCategoryTagEntity { Name = name, OrderIndex = 1000, Active = false }; + db.ClubCategoryTags.Add(tag); + await db.SaveChangesAsync(); + } + db.ClubCategoryAssignments.Add(new ClubCategoryAssignmentEntity + { + ClubId = clubId, + CategoryTagId = tag.Id, + }); + } + await db.SaveChangesAsync(); + } + + /// + /// The permission record for one role on a club. + /// + /// These three envelope keys used to carry a plain bitmask int. Both + /// clients declare them as a COMPLEX OBJECT — 2023 + /// LCLFBBPEMIH.JNOKOILEFHD/OMMHPBFEHBH/IJKKPOBDJGG are + /// MMOCDPPONNG (recnet-runtime-decomp/LCLFBBPEMIH.cs:64-115) and the + /// 2020 watch's PIHMJGCGNLP uses the identically-laid-out + /// JHEEFBMODPG — so feeding an integer into the object reader threw + /// and took the whole club page (and club creation) down with it. + /// + /// Key names are verbatim from the generated reader + /// BPHCOIBNCDP.txt:587-766: ClubId, Type (not + /// "MembershipType"), EditDetails, ApproveMember, CreateEvent, + /// PostAnnouncement, EditPermissionSettings, BanUnban. The same six names + /// are the form keys the client PUTs back on the permissions screen + /// (IKMMOCKDKAF_NestedType_BOIMHOCCOEI.txt:173-253). + /// + /// The bools are derived from the role rather than stored: there is no + /// per-role permission table yet, so every club uses the default policy — + /// Owner/CoOwner do everything, Moderator moderates but cannot re-write the + /// permission settings, Member and below are read-only. + /// + private readonly record struct RolePermissions( + bool EditDetails, bool ApproveMember, bool CreateEvent, + bool PostAnnouncement, bool EditPermissionSettings, bool BanUnban); + + /// The stored permission row for a role, or the derived default + /// when the club has never customised it — Owner/CoOwner do everything, + /// Moderator moderates but cannot rewrite permissions, Member and below are + /// read-only. Falling back rather than defaulting to false keeps every club + /// that predates the table behaving exactly as it did. + private async Task ResolveRolePermissionsAsync(long clubId, int membershipType) + { + var stored = await db.ClubRolePermissions + .FirstOrDefaultAsync(p => p.ClubId == clubId && p.MembershipType == membershipType); + if (stored is not null) + return new RolePermissions( + stored.EditDetails, stored.ApproveMember, stored.CreateEvent, + stored.PostAnnouncement, stored.EditPermissionSettings, stored.BanUnban); + + var admin = membershipType is ClubService.MembershipTypeOwner + or ClubService.MembershipTypeCoOwner; + var moderator = admin || membershipType == ClubService.MembershipTypeModerator; + return new RolePermissions(admin, moderator, moderator, moderator, admin, moderator); + } + + private async Task PermissionsForRoleAsync(long clubId, int membershipType) + { + var p = await ResolveRolePermissionsAsync(clubId, membershipType); + return new + { + ClubId = clubId, + Type = membershipType, + p.EditDetails, + p.ApproveMember, + p.CreateEvent, + p.PostAnnouncement, + p.EditPermissionSettings, + p.BanUnban, + }; + } private static object ToWireMembership(ClubMembershipEntity row) => new { @@ -863,6 +1777,13 @@ public class ClubsController(ClubService clubs) : ControllerBase Joinability = c.Joinability, AllowJuniors = c.AllowJuniors, MemberCount = memberCount, + // MinLevel + ClubChatEnabled are registered by the 2023 Club reader + // (BLIBJIHOENF.txt:1234-1322) and were never emitted, so every club came + // back with chat disabled on the profile Clubs tab. Both are real + // columns now, written by club/{0}/minlevel and + // club/{0}/clubChatEnabled (IKMMOCKDKAF.txt:16561, :25779). + c.MinLevel, + c.ClubChatEnabled, IsRRO = c.IsRRO, ClubhouseRoomId = c.ClubhouseRoomId, ClubType = c.ClubType, @@ -927,28 +1848,74 @@ public class ClubsController(ClubService clubs) : ControllerBase }; /// - /// Group a flat list of announcements into the per-club - /// HPACLJHLHBG envelope shape. Uses - /// for the outer object - /// because the wire mixes lowercase and PascalCase keys for the - /// same record (clubId + LastReadAnnouncementId + - /// announcements) and anonymous types can't model that. + /// Group a flat list of announcements into per-club envelopes — the shape + /// the two unread rollups return as a LIST (2023 + /// List<NCHLBFPHFJE>, 2020 List<EIKPFIDCKNE>). /// private static List> GroupByClub( - List rows) + List rows, Dictionary lastReadByClub) { return rows .GroupBy(a => a.ClubId) - .Select(g => - { - var ordered = g.OrderByDescending(a => a.CreatedAt).ToList(); - return new Dictionary - { - ["clubId"] = g.Key, - ["LastReadAnnouncementId"] = (long?)null, - ["announcements"] = ordered.Select(ToWireAnnouncement).ToList(), - }; - }) + .Select(g => AnnouncementEnvelope(g.Key, g.ToList(), lastReadByClub.GetValueOrDefault(g.Key))) .ToList(); } + + /// + /// One club's announcement envelope. Uses + /// rather than an anonymous type + /// because the wire mixes casings inside a single record: the 2020 readers + /// match key names EXACTLY and want clubId + announcements + /// lowercase but LastAnnouncementId/LastReadAnnouncementId + /// PascalCase (EIKPFIDCKNE.txt, HPACLJHLHBG.txt:270-280). + /// The 2023 readers are tri-cased (CAKCKAMAAPP.txt:331-414, + /// AECJMONHBHM.txt:267-326) so camelCase satisfies them too. + /// + /// Two fixes over the original projection: + /// * LastAnnouncementId was omitted entirely though both readers + /// register it; + /// * LastReadAnnouncementId was an explicit JSON null into + /// a NON-nullable Int64 field on both clients. Always a number now. + /// + private static Dictionary AnnouncementEnvelope( + long clubId, List rows, long lastReadId) + { + var ordered = rows.OrderByDescending(a => a.CreatedAt).ThenByDescending(a => a.Id).ToList(); + return new Dictionary + { + ["clubId"] = clubId, + ["LastAnnouncementId"] = ordered.Count == 0 ? 0L : ordered.Max(a => a.Id), + ["LastReadAnnouncementId"] = lastReadId, + ["announcements"] = ordered.Select(ToWireAnnouncement).ToList(), + }; + } + + /// + /// Highest announcement id the caller has actually read, per club — the + /// LastReadAnnouncementId the envelopes above must carry. Done as + /// two index-supported reads plus an in-memory join for the same reason + /// avoids the LEFT JOIN: EF Core generates + /// pathological SQL for the anti-join on SQLite. + /// + private async Task> LastReadByClubAsync( + long playerId, IEnumerable clubIds) + { + var ids = clubIds.Distinct().ToList(); + if (ids.Count == 0) return new Dictionary(); + + var readIds = await db.ClubAnnouncementReads + .Where(r => r.PlayerId == playerId) + .Select(r => r.AnnouncementId) + .ToListAsync(); + if (readIds.Count == 0) return new Dictionary(); + + var read = await db.ClubAnnouncements + .Where(a => ids.Contains(a.ClubId) && readIds.Contains(a.Id)) + .Select(a => new { a.ClubId, a.Id }) + .ToListAsync(); + + return read + .GroupBy(a => a.ClubId) + .ToDictionary(g => g.Key, g => g.Max(a => a.Id)); + } } diff --git a/DorkNet.Server/Controllers/Econ/EconController.cs b/DorkNet.Server/Controllers/Econ/EconController.cs index dedf6ab..59c8625 100644 --- a/DorkNet.Server/Controllers/Econ/EconController.cs +++ b/DorkNet.Server/Controllers/Econ/EconController.cs @@ -98,12 +98,14 @@ public class EconController(DorkNetDbContext db, LevelService level) : Controlle }); } + /// The client reads this body as a BARE integer — the issuing + /// method is FGLDKEJLAKB<System.Int32> KPOENLDFAJD() + /// (RecNet.Runtime/MPBLNLMCEDL.txt:2881, route literal on :2955). + /// Returning a JSON object made the deserialize throw, so the ownership-cap + /// check that runs before a custom-shirt purchase always errored out. [HttpGet("/econ/customAvatarItems/v1/itemOwnershipLimit")] - public IActionResult CustomAvatarItemOwnershipLimit() => Ok(new - { - Limit = 1000, - ItemOwnershipLimit = 1000, - }); + public IActionResult CustomAvatarItemOwnershipLimit() + => Content("1000", "application/json"); private static object ToCustomAvatarItemWire(DorkNet.Server.Data.Entities.CustomAvatarItemEntity i) => new { diff --git a/DorkNet.Server/Controllers/Link/LinkController.cs b/DorkNet.Server/Controllers/Link/LinkController.cs new file mode 100644 index 0000000..47ba3c5 --- /dev/null +++ b/DorkNet.Server/Controllers/Link/LinkController.cs @@ -0,0 +1,441 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using DorkNet.Server.Auth; +using DorkNet.Server.Binding; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace DorkNet.Server.Controllers.Link; + +/// +/// The Link host (GJDLNNLKDIJ.Link = host ordinal 11) share-code +/// service — RecNet.Runtime/CKBKHENHCAN. Every "share this" surface in +/// the 2023 client funnels through here: +/// RecRoom.Sharing.ActionCode subclasses (FriendCode, ReferralCode, +/// MeetupCode, ClubCode, PlayerEventCode, RoomCode, InfluencerCode, PhotoCode) +/// create an action link — a short code the player reads out or pastes, +/// which the receiving client redeems — while LinkManager stores the +/// larger opaque payload behind a share URL as a data link. +/// +/// The five routes and their verbs, read off the dispatch call in +/// CKBKHENHCAN.txt (the shape is +/// (this, verb=rdx, host=r8, path=r9, body=stack), verb being a +/// BestHTTP.HTTPMethods ordinal — GET=0, POST=2, PUT=3): +/// +/// GET actionlink/{code} — CGIEJDIHJBD (:635), verb +/// 045 Move rdx, 0 (:793), host 050 LoadAddress r8, [rdx+11] +/// (:798), literal "actionlink/" (:786). +/// POST actionlink — PCNPNDPAFLK (:860), verb +/// 106 Move rdx, 2 (:1226), host 105 Move r8, 11 (:1225), +/// literal "actionlink" (:1224). +/// POST actionlink/{code}/consume — MKAKECJOKDG (:1380), +/// verb 077 Move rdx, 2 (:1675), path built by +/// String.Concat("actionlink/", WebUtility.UrlEncode(code), "/consume") +/// (:1665-1669). +/// PUT datalink — ONPLPBLKACA (:419), verb +/// 033 Move rdx, 3 (:560), literal "datalink" (:558). +/// GET datalink/{code} — KEHDDPCHPGK (:194), verb +/// 045 Move rdx, 0 (:352), literal "datalink/" (:345). +/// +/// All five were unrouted, so every share/redeem flow 404'd. +/// +/// Storage. Codes live in PlayerSettings — the server's +/// existing general-purpose per-player key/value table, already used for +/// ban-appeal codes (CompatibilityFeatureController) and external +/// friend invites (ExternalFriendInviteController) — under +/// actionlink:{CODE} / datalink:{CODE} keys on the creator's +/// row. That keeps codes durable across restarts without a schema change; the +/// cost is the 1024-character cap, +/// which is enforced explicitly below rather than left to trip a Postgres +/// varchar(1024) constraint at SaveChanges. +/// +[ApiController] +public class LinkController(DorkNetDbContext db, ILogger logger) : ControllerBase +{ + private const string ActionPrefix = "actionlink:"; + private const string DataPrefix = "datalink:"; + + /// Unambiguous uppercase alphabet — no O/0/I/1 — because action + /// codes are read aloud and re-typed into + /// ActionCodeConsumptionModel's input field. + private const string Alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; + + /// PlayerSettingEntity.Value is [MaxLength(1024)]. + private const int ValueLimit = 1024; + + /// RecRoom.Sharing.CBKKGBIJHAL (2023.06.21 dump.cs:1248232): + /// Unknown=-1, Friend=0, Referral=1, Meetup=2, Club=3, PlayerEvent=4, + /// Room=5, Influencer=6, Photo=7. Confirmed live at the consume call sites + /// — RoomCode passes 030 Move r8, 5 + /// (RoomCode_NestedType_IBLIEDEHMPI.txt:175) and ClubCode passes + /// 029 Move r8, 3 (ClubCode_NestedType_EAGCMJOEANN.txt:85). + private const int CodeTypeReferral = 1; + + // ── actionlink ──────────────────────────────────────────────────────────── + + /// POST actionlink — mint a share code. + /// + /// Form fields are added one at a time by + /// BNDIAONDFFF.AFGEDDANEKP: data (:1234), validHours + /// (:1249), maxCount (:1261, boxed Nullable<Int32> — + /// omitted when null), codeType (:1273) and extraDataId + /// (:1287, boxed Nullable<Int64>). The explicit + /// [ModelBinder] is mandatory: [ApiController] would + /// otherwise re-infer [FromBody] and 415 the form post. + /// + /// The issuing method is + /// FGLDKEJLAKB<System.String> PCNPNDPAFLK(...) (:860), so the + /// body is a bare JSON string — the code itself. Ok(string) + /// would go out as unquoted text/plain via + /// StringOutputFormatter, which the client's String formatter + /// cannot read, hence the explicit Content(JsonSerializer.Serialize(…)) + /// (same pattern as the ban-appeal code handler). + [HttpPost("/actionlink")] + [Authorize] + public async Task ActionLinkCreate( + [ModelBinder(typeof(FormOrJsonModelBinder))] ActionLinkCreateRequest req) + { + var me = this.RequireCurrentPlayerId(); + var data = req.Data ?? string.Empty; + + var payload = new ActionLinkPayload + { + Data = data, + ExpiresAt = req.ValidHours > 0 + ? (DateTime?)DateTime.UtcNow.AddHours(req.ValidHours) + : null, + MaxCount = req.MaxCount is > 0 ? req.MaxCount : (int?)null, + Uses = 0, + CodeType = req.CodeType, + ExtraDataId = req.ExtraDataId, + }; + + var encoded = JsonSerializer.Serialize(payload); + if (encoded.Length > ValueLimit) + { + logger.LogWarning( + "[link] actionlink payload too large player={PlayerId} codeType={CodeType} bytes={Length} limit={Limit}", + me, req.CodeType, encoded.Length, ValueLimit); + return BadRequest(new { Error = "Action link data is too large to store." }); + } + + var code = await ReserveCodeAsync(ActionPrefix, 8); + if (code is null) return StatusCode(StatusCodes.Status503ServiceUnavailable); + + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = me, + Key = ActionPrefix + code, + Value = encoded, + }); + await db.SaveChangesAsync(); + + logger.LogInformation( + "[link] actionlink created code={Code} player={PlayerId} codeType={CodeType} validHours={ValidHours} maxCount={MaxCount}", + code, me, req.CodeType, req.ValidHours, req.MaxCount); + return Content(JsonSerializer.Serialize(code), "application/json"); + } + + /// GET actionlink/{code} — peek at a code without spending + /// a use. + /// + /// Anonymous on purpose: ActionCode.ModifyPreAuthLaunchTarget + /// resolves an inbound deep link during BootSequence, i.e. before + /// the player has a token. + /// + /// Return type FGLDKEJLAKB<NBAFGFFIOLK> (:635); the + /// generated formatter DPIECEMDKPL registers exactly three members — + /// CreatorPlayerId (:267, Int32), Data (:294, String), + /// IsValid (:310, Boolean) — each also in camelCase (:278/:302/:318) + /// and lowercase (:286/:326), so the server's camelCase anonymous-object + /// serialization binds. An unknown code answers 200 with + /// IsValid:false rather than 404 so the client always gets a body it + /// can parse instead of faulting the promise. + [HttpGet("/actionlink/{code}")] + [AllowAnonymous] + public async Task ActionLinkGet(string code) + { + var row = await FindAsync(ActionPrefix, code); + var payload = Decode(row); + if (row is null || payload is null) return Ok(ActionLinkResult(0, string.Empty, false)); + + var valid = IsUsable(payload); + return Ok(ActionLinkResult((int)row.PlayerId, valid ? payload.Data : string.Empty, valid)); + } + + /// POST actionlink/{code}/consume — redeem a code. + /// + /// Form fields: id (:1684 — the code again), validHours + /// (:1692, Nullable<Int32>), codeType (:1720), + /// newPlayer (:1732) and newInstall (:1745). Response shape + /// is the same NBAFGFFIOLK as the GET (:1380). + /// + /// validHours here is the creating ActionCode's + /// Configuration.AutoRenewHours — the call sites load it immediately + /// before dispatch (043 Call Configuration.get_AutoRenewHours, + /// RoomCode_NestedType_IBLIEDEHMPI.txt:168; ClubCode_NestedType_EAGCMJOEANN.txt:78) + /// and it is null for codes whose AutoRenew is off. So a + /// present value means "this code renews on use": the expiry is pushed out + /// that many hours instead of the code burning down. + /// + /// codeType is checked against the stored one — a Room code + /// (5) redeemed through the Club (3) flow would otherwise hand the club + /// handler a room payload to parse. + /// + /// Referral codes are the one type with no other server touchpoint: + /// ReferralCode has no HTTP routes of its own, and + /// IncentivizedReferralsController counts + /// referral:credited:{inviterId}:{inviteeId} rows that nothing else + /// writes. Redeeming one here as a new player is what credits the + /// inviter. + /// + /// Anonymous like the GET — the consume can fire from the pre-auth + /// launch-target path; the caller's id is only needed for referral + /// crediting. + [HttpPost("/actionlink/{code}/consume")] + [AllowAnonymous] + public async Task ActionLinkConsume( + string code, + [ModelBinder(typeof(FormOrJsonModelBinder))] ActionLinkConsumeRequest req) + { + // The client sends the code in the path AND as the `id` field; prefer + // the path, fall back to the field. + var wanted = string.IsNullOrWhiteSpace(code) ? req.Id : code; + var row = await FindAsync(ActionPrefix, wanted); + var payload = Decode(row); + if (row is null || payload is null) + { + logger.LogInformation("[link] actionlink consume miss code={Code}", Normalize(wanted)); + return Ok(ActionLinkResult(0, string.Empty, false)); + } + + if (!IsUsable(payload) || payload.CodeType != req.CodeType) + { + logger.LogInformation( + "[link] actionlink consume rejected code={Code} storedType={StoredType} askedType={AskedType} uses={Uses}/{MaxCount} expires={Expires}", + Normalize(wanted), payload.CodeType, req.CodeType, payload.Uses, payload.MaxCount, payload.ExpiresAt); + return Ok(ActionLinkResult((int)row.PlayerId, string.Empty, false)); + } + + payload.Uses++; + // AutoRenewHours present ⇒ the code renews rather than ages out. + if (req.ValidHours is > 0) payload.ExpiresAt = DateTime.UtcNow.AddHours(req.ValidHours.Value); + + var encoded = JsonSerializer.Serialize(payload); + if (encoded.Length <= ValueLimit) row.Value = encoded; + + var consumer = this.CurrentPlayerId(); + await CreditReferralAsync(payload, row.PlayerId, consumer, req.NewPlayer); + await db.SaveChangesAsync(); + + logger.LogInformation( + "[link] actionlink consumed code={Code} creator={Creator} consumer={Consumer} type={CodeType} uses={Uses} newPlayer={NewPlayer} newInstall={NewInstall}", + Normalize(wanted), row.PlayerId, consumer ?? 0, payload.CodeType, payload.Uses, req.NewPlayer, req.NewInstall); + return Ok(ActionLinkResult((int)row.PlayerId, payload.Data, true)); + } + + // ── datalink ────────────────────────────────────────────────────────────── + + /// PUT datalink — stash the payload behind a share URL. + /// + /// Single form field data (:569, added right after the + /// 033 Move rdx, 3 PUT dispatch at :560). Return type is + /// FGLDKEJLAKB<System.String> (:419) — a bare JSON string + /// holding the generated code, so the same explicit + /// Content(JsonSerializer.Serialize(…), "application/json") as the + /// action-link create. + /// + /// Codes are 10 characters here rather than 8: nobody types a + /// datalink code, it only ever travels inside a URL, so the extra entropy + /// is free. + [HttpPut("/datalink")] + [Authorize] + public async Task DataLinkCreate( + [ModelBinder(typeof(FormOrJsonModelBinder))] DataLinkCreateRequest req) + { + var me = this.RequireCurrentPlayerId(); + var data = req.Data ?? string.Empty; + if (data.Length > ValueLimit) + { + logger.LogWarning( + "[link] datalink payload too large player={PlayerId} bytes={Length} limit={Limit}", + me, data.Length, ValueLimit); + return BadRequest(new { Error = "Data link payload is too large to store." }); + } + + var code = await ReserveCodeAsync(DataPrefix, 10); + if (code is null) return StatusCode(StatusCodes.Status503ServiceUnavailable); + + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = me, + Key = DataPrefix + code, + Value = data, + }); + await db.SaveChangesAsync(); + + logger.LogInformation("[link] datalink created code={Code} player={PlayerId} bytes={Length}", + code, me, data.Length); + return Content(JsonSerializer.Serialize(code), "application/json"); + } + + /// GET datalink/{code} — resolve a share URL back to its + /// payload. + /// + /// Return type FGLDKEJLAKB<LBBKBMPAEIO> (:194) whose + /// generated formatter IKBPJACLIHH registers a single member, + /// Data (:139) — plus the data (:150) alias the server's + /// camelCase serializer emits. Anonymous, like the action-link read: this + /// is resolved from a cold-boot deep link. An unknown code returns an empty + /// Data rather than 404 so the strict reader still gets an + /// object. + [HttpGet("/datalink/{code}")] + [AllowAnonymous] + public async Task DataLinkGet(string code) + { + var row = await FindAsync(DataPrefix, code); + return Ok(new { Data = row?.Value ?? string.Empty }); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + /// Codes are stored uppercase so a player who types their share + /// code in lowercase still redeems it. + private static string Normalize(string? code) + => (code ?? string.Empty).Trim().ToUpperInvariant(); + + private Task FindAsync(string prefix, string? code) + { + var normalized = Normalize(code); + if (normalized.Length == 0) return Task.FromResult(null); + var key = prefix + normalized; + return db.PlayerSettings.FirstOrDefaultAsync(s => s.Key == key); + } + + /// Generates a code that is free across every player's rows (the + /// unique index is on (PlayerId, Key), but a code has to be globally + /// unique because lookups are by key alone). Retries a handful of times + /// before giving up rather than colliding on insert. + private async Task ReserveCodeAsync(string prefix, int length) + { + for (var attempt = 0; attempt < 6; attempt++) + { + var code = NewCode(length); + var key = prefix + code; + if (!await db.PlayerSettings.AnyAsync(s => s.Key == key)) return code; + } + logger.LogError("[link] exhausted code-generation attempts for prefix={Prefix}", prefix); + return null; + } + + private static string NewCode(int length) + { + var chars = new char[length]; + for (var i = 0; i < length; i++) + chars[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)]; + return new string(chars); + } + + private ActionLinkPayload? Decode(PlayerSettingEntity? row) + { + if (row is null) return null; + try + { + return JsonSerializer.Deserialize(row.Value); + } + catch (JsonException ex) + { + logger.LogWarning(ex, "[link] unreadable action-link row id={RowId} key={Key}", row.Id, row.Key); + return null; + } + } + + private static bool IsUsable(ActionLinkPayload payload) + { + if (payload.ExpiresAt is { } expiry && expiry <= DateTime.UtcNow) return false; + if (payload.MaxCount is { } max && payload.Uses >= max) return false; + return true; + } + + /// Records "consumer was referred by creator" in the + /// shape IncentivizedReferralsController already reads + /// (referral:credited:{inviterId}:{inviteeId}, counted by key prefix + /// regardless of which row owns it — stored on the invitee, matching the + /// externalinvite:redeemed:* convention). Only fires for referral + /// codes redeemed by a signed-in, brand-new player who is not the + /// creator. + private async Task CreditReferralAsync( + ActionLinkPayload payload, long creatorId, long? consumerId, bool newPlayer) + { + if (payload.CodeType != CodeTypeReferral) return; + if (!newPlayer) return; + if (consumerId is not { } consumer || consumer == creatorId) return; + + var key = $"referral:credited:{creatorId}:{consumer}"; + if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == consumer && s.Key == key)) return; + + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = consumer, + Key = key, + Value = DateTime.UtcNow.ToString("O"), + }); + logger.LogInformation("[link] referral credited inviter={Inviter} invitee={Invitee}", creatorId, consumer); + } + + private static object ActionLinkResult(int creatorPlayerId, string data, bool isValid) => new + { + CreatorPlayerId = creatorPlayerId, + Data = data, + IsValid = isValid, + }; + + /// Everything the client told us at create time, packed into the + /// single PlayerSettings.Value string. Keys are one character each + /// to leave as much of the 1024-character budget as possible for + /// . + private sealed class ActionLinkPayload + { + [JsonPropertyName("d")] public string Data { get; set; } = string.Empty; + [JsonPropertyName("e")] public DateTime? ExpiresAt { get; set; } + [JsonPropertyName("m")] public int? MaxCount { get; set; } + [JsonPropertyName("u")] public int Uses { get; set; } + [JsonPropertyName("t")] public int CodeType { get; set; } + [JsonPropertyName("x")] public long? ExtraDataId { get; set; } + } +} + +/// Form fields of POST actionlink — CKBKHENHCAN.txt:1234 +/// (data), :1249 (validHours), :1261 (maxCount), :1273 +/// (codeType), :1287 (extraDataId). +public sealed class ActionLinkCreateRequest +{ + public string? Data { get; set; } + public int ValidHours { get; set; } + public int? MaxCount { get; set; } + public int CodeType { get; set; } + public long? ExtraDataId { get; set; } +} + +/// Form fields of POST actionlink/{code}/consume — +/// CKBKHENHCAN.txt:1684 (id), :1692 (validHours), :1720 +/// (codeType), :1732 (newPlayer), :1745 +/// (newInstall). +public sealed class ActionLinkConsumeRequest +{ + public string? Id { get; set; } + public int? ValidHours { get; set; } + public int CodeType { get; set; } + public bool NewPlayer { get; set; } + public bool NewInstall { get; set; } +} + +/// Sole form field of PUT datalink — CKBKHENHCAN.txt:569. +public sealed class DataLinkCreateRequest +{ + public string? Data { get; set; } +} diff --git a/DorkNet.Server/Controllers/Match/MatchController.cs b/DorkNet.Server/Controllers/Match/MatchController.cs index a9caf8d..f651bc0 100644 --- a/DorkNet.Server/Controllers/Match/MatchController.cs +++ b/DorkNet.Server/Controllers/Match/MatchController.cs @@ -323,6 +323,10 @@ public class MatchController( /// PrivateInstance row. The watch only needs a RecNet result /// wrapper, but the server persists the privacy state so later /// invite joins are enforced by . + // The 2023-03-21 client sends PUT (verb constant 3 at + // RecNet/Matchmaking.txt:15483 and :15774) with an empty body. POST-only + // returned 405, so a host could never close a public instance to private. + [HttpPut("/roominstance/{instanceId:long}/markprivate")] [HttpPost("/roominstance/{instanceId:long}/markprivate")] public async Task MarkPrivate(long instanceId) { @@ -357,14 +361,77 @@ public class MatchController( return Ok(new { Success = true, Error = string.Empty }); } - /// GET/POST /roominstance/{id}/roomcode — raw - /// JSON string room code shown by the watch. Codes are stable for - /// an instance id and do not expose the numeric id directly. + /// GET/POST/PUT /roominstance/{id}/roomcode — the room code + /// shown by the watch, as a raw JSON string. + /// + /// GET returns the instance's code. The 2023-03-21 client also PUTs here to + /// SET a custom code, sending form fields roomCode and + /// forceChange (verb 3 at RecNet/Matchmaking.txt:16234). PUT was not + /// registered, so custom room codes 405'd; and because the handler always + /// regenerated the deterministic code, a submitted one would have been + /// discarded anyway. A submitted code is now validated, claimed and + /// persisted, and the accepted code is echoed back. + /// + /// forceChange lets the host take a code already claimed by one of + /// their own instances; it never steals another player's code. [HttpGet("/roominstance/{instanceId:long}/roomcode")] [HttpPost("/roominstance/{instanceId:long}/roomcode")] - public IActionResult RoomCode(long instanceId) + [HttpPut("/roominstance/{instanceId:long}/roomcode")] + public async Task RoomCode( + long instanceId, + [FromForm(Name = "roomCode")] string? roomCode, + [FromForm(Name = "forceChange")] bool? forceChange) { - var code = RoomCodeService.Generate(instanceId); + // Same normalisation the join path applies (GoToController.GoToCode → + // RoomCodeService.Normalize), so a code typed with dashes/lowercase on + // the watch still matches what we persist. + var normalized = RoomCodeService.Normalize( + roomCode ?? Request.Query["roomCode"].FirstOrDefault()); + + if (normalized.Length > 0) + { + var pid = this.RequireCurrentPlayerId(); + var clash = await db.PrivateInstances + .Where(p => p.RoomCode == normalized && p.Id != instanceId) + .Select(p => new { p.Id, p.OwnerPlayerId }) + .FirstOrDefaultAsync(); + + // forceChange only lets a host reclaim a code from one of their OWN + // instances; another player's code is never stolen. On refusal the + // client just re-reads whatever code we echo back, so we fall + // through to the read path instead of erroring. + var blocked = clash is not null + && (clash.OwnerPlayerId != pid || forceChange != true); + + var row = await db.PrivateInstances.FirstOrDefaultAsync(p => p.Id == instanceId); + if (!blocked && row is not null) + { + if (clash is not null) + { + // Release the caller's older claim first, as its own + // statement, so the two rows never hold the code at once. + await db.PrivateInstances + .Where(p => p.Id == clash.Id) + .ExecuteUpdateAsync(s => s.SetProperty(p => p.RoomCode, string.Empty)); + } + row.RoomCode = normalized; + await db.SaveChangesAsync(); + logger.LogInformation( + "[roomcode] player={Player} instance={Instance} code={Code} force={Force}", + pid, instanceId, normalized, forceChange == true); + return Content(JsonSerializer.Serialize(normalized), "application/json"); + } + + logger.LogWarning( + "[roomcode] player={Player} instance={Instance} refused code={Code} taken={Taken} known={Known}", + pid, instanceId, normalized, clash is not null, row is not null); + } + + var stored = await db.PrivateInstances + .Where(p => p.Id == instanceId) + .Select(p => p.RoomCode) + .FirstOrDefaultAsync(); + var code = string.IsNullOrEmpty(stored) ? RoomCodeService.Generate(instanceId) : stored; return Content(JsonSerializer.Serialize(code), "application/json"); } diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index fd96f96..217bcae 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -112,12 +112,17 @@ public class MatchPlayerController( /// [HttpPost("/player/notifydisconnect")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] - public IActionResult NotifyDisconnect([FromForm(Name = "otherPlayerId")] int? otherPlayerId) + public IActionResult NotifyDisconnect( + [FromForm(Name = "otherPlayerId")] int? otherPlayerId, + // The 2023-03-21 client sends PascalCase PlayerId + RoomInstanceId + // instead of 2020's otherPlayerId, so the legacy binding logged null. + [FromForm(Name = "PlayerId")] long? playerId, + [FromForm(Name = "RoomInstanceId")] long? roomInstanceId) { var caller = TryGetCurrentPlayerId(); log.LogInformation( - "[player-notify-disconnect] caller={Caller} reported other={Other} left", - caller, otherPlayerId); + "[player-notify-disconnect] caller={Caller} reported other={Other} instance={Instance} left", + caller, otherPlayerId ?? (int?)playerId, roomInstanceId); return Ok(); } @@ -158,9 +163,12 @@ public class MatchPlayerController( return Ok(new PresenceDto { PlayerId = playerId, - StatusVisibility = 0, // Everyone (was 1 = FriendsOnly) + // Defaults: 0 = Everyone. Both come from the player's stored + // preference so a value set via /player/statusvisibility or + // /player/vrmovementmode survives the next heartbeat. + StatusVisibility = await GetPlayerPrefIntAsync(playerId, PrefStatusVisibility, 0), DeviceClass = 0, - VrMovementMode = 0, + VrMovementMode = await GetPlayerPrefIntAsync(playerId, PrefVrMovementMode, 0), RoomInstance = room, }); } @@ -170,13 +178,14 @@ public class MatchPlayerController( public async Task> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value) { var playerId = TryGetCurrentPlayerId(); + if (value is int v) await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString()); var room = await GetPresenceRoomForResponseAsync(playerId); return Ok(new PresenceDto { PlayerId = playerId, - StatusVisibility = value ?? 0, // default Everyone; honor explicit client choice + StatusVisibility = value ?? await GetPlayerPrefIntAsync(playerId, PrefStatusVisibility, 0), DeviceClass = 0, - VrMovementMode = 0, + VrMovementMode = await GetPlayerPrefIntAsync(playerId, PrefVrMovementMode, 0), RoomInstance = room, }); } @@ -186,17 +195,58 @@ public class MatchPlayerController( public async Task> SetVrMovementMode([FromForm(Name = "vrMovementMode")] int? value) { var playerId = TryGetCurrentPlayerId(); + if (value is int v) await SetPlayerPrefAsync(playerId, PrefVrMovementMode, v.ToString()); var room = await GetPresenceRoomForResponseAsync(playerId); return Ok(new PresenceDto { PlayerId = playerId, - StatusVisibility = 0, // Everyone (was 1 = FriendsOnly) + StatusVisibility = await GetPlayerPrefIntAsync(playerId, PrefStatusVisibility, 0), DeviceClass = 0, - VrMovementMode = value ?? 0, + VrMovementMode = value ?? await GetPlayerPrefIntAsync(playerId, PrefVrMovementMode, 0), RoomInstance = room, }); } + // ── per-player matchmaking preferences ─────────────────────────── + // These three were previously echo-only: the client's chosen value came + // back in the response and was then dropped, so the setting reverted on the + // next heartbeat and never survived a restart. They live in PlayerSettings + // (no schema change needed) and are read back wherever presence is built. + private const string PrefStatusVisibility = "pref:statusVisibility"; + private const string PrefVrMovementMode = "pref:vrMovementMode"; + private const string PrefAvoidJuniors = "pref:avoidJuniors"; + + private async Task SetPlayerPrefAsync(long playerId, string key, string value) + { + if (playerId == 0) return; + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key); + if (row is null) + { + db.PlayerSettings.Add(new DorkNet.Server.Data.Entities.PlayerSettingEntity + { + PlayerId = playerId, Key = key, Value = value, + }); + } + else + { + row.Value = value; + } + await db.SaveChangesAsync(); + } + + private async Task GetPlayerPrefAsync(long playerId, string key) + { + if (playerId == 0) return null; + return await db.PlayerSettings + .Where(s => s.PlayerId == playerId && s.Key == key) + .Select(s => s.Value) + .FirstOrDefaultAsync(); + } + + private async Task GetPlayerPrefIntAsync(long playerId, string key, int fallback) + => int.TryParse(await GetPlayerPrefAsync(playerId, key), out var v) ? v : fallback; + /// /// GET /player/avoidjuniors — returns a primitive boolean. /// Verified against @@ -209,18 +259,28 @@ public class MatchPlayerController( /// matchmaking init in a never-completes state on some boot flows. /// [HttpGet("/player/avoidjuniors")] - public ActionResult GetAvoidJuniors() => Ok(false); + public async Task> GetAvoidJuniors() + { + var playerId = TryGetCurrentPlayerId(); + var stored = await GetPlayerPrefAsync(playerId, PrefAvoidJuniors); + return Ok(bool.TryParse(stored, out var v) && v); + } /// PUT /player/avoidjuniors — body - /// avoidJuniors=true|false (Matchmaking.txt:7883). Stub for - /// now; the multi-tenant server doesn't currently track this - /// preference per-player. Returns the new value as a JSON + /// avoidJuniors=true|false (Matchmaking.txt:7883). Persisted + /// per-player in PlayerSettings and read back by + /// . Returns the new value as a JSON /// primitive so the watch's ExpectPrimitiveResponse is happy. [HttpPut("/player/avoidjuniors")] [HttpPost("/player/avoidjuniors")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] - public ActionResult SetAvoidJuniors([FromForm(Name = "avoidJuniors")] bool? value) - => Ok(value ?? false); + public async Task> SetAvoidJuniors([FromForm(Name = "avoidJuniors")] bool? value) + { + var playerId = TryGetCurrentPlayerId(); + if (value is bool v) + await SetPlayerPrefAsync(playerId, PrefAvoidJuniors, v ? "true" : "false"); + return Ok(value ?? false); + } /// POST /player/photonregionpings — watch reports /// its measured Photon region ping vector so the server can pick @@ -260,6 +320,27 @@ public class MatchPlayerController( // Sanity bound — discard wild values from misbehaving clients. pairs[r] = Math.Clamp(p, 0, 2000); } + + // The 2023-03-21 client does NOT use the repeated (region, ping) pair + // shape above. It sends one field per measured region, keyed by the + // region name with the ping as the value (us=42&eu=90&...). Against the + // pair parser both arrays came back empty, so no 2023 client ever + // registered a ping and region selection fell back to the default. + if (pairs.Count == 0) + { + var fields = Request.HasFormContentType + ? (IEnumerable>)Request.Form + : Request.Query; + foreach (var (key, value) in fields) + { + var name = key?.Trim(); + if (string.IsNullOrEmpty(name)) continue; + if (name.Equals("region", StringComparison.OrdinalIgnoreCase) || + name.Equals("ping", StringComparison.OrdinalIgnoreCase)) continue; + if (!int.TryParse(value.FirstOrDefault(), out var p)) continue; + pairs[name] = Math.Clamp(p, 0, 2000); + } + } if (pairs.Count > 0) presence.SetPhotonRegionPings(playerId, pairs); log.LogInformation("[player-region-pings] player={Player} regions={Count}", playerId, pairs.Count); @@ -282,12 +363,34 @@ public class MatchPlayerController( var pending = joinTimeouts.GetPending(playerId); if (pending is { DeferPresenceCommit: true }) { + // Report the instance the player is joining — NOT null. + // + // Deferring the commit is about what everyone ELSE sees: presence + // stays uncommitted until the join is confirmed, so a failed join + // never leaves a ghost in the room. It was never meant to change + // what we tell the joining player about themselves. + // + // Returning null did exactly that, and the client treats it as the + // room vanishing mid-join: OnPresenceHeartbeatResponse XORs + // (cachedRoomInstance != null) against (serverRoomInstance != null), + // so a client holding the target while we say null fires + // "presence heartbeat response indicates local presence is + // out-of-sync", then "Attempting to go to Invalid RoomInstance" + // and cancels its own load ("OnPlayerPresenceUpdate is cancelling + // room load"). The join it aborted was the one we were waiting on, + // so it retried until a heartbeat happened to miss the window — + // entering a dorm took several attempts and looked like a load + // failure. The branch below documents this same XOR for the + // boot-race case; this one contradicted it. + // + // Echoing the pending target satisfies the XOR and matches what the + // client already has, while presence itself stays uncommitted. log.LogInformation( - "[player-heartbeat] player={PlayerId} pending deferred room={RoomId} instance={InstanceId}; returning null until join result", + "[player-heartbeat] player={PlayerId} pending deferred room={RoomId} instance={InstanceId}; echoing target until join result", playerId, pending.Value.TargetRoom.RoomId, pending.Value.TargetRoom.RoomInstanceId); - return null; + return pending.Value.TargetRoom; } if (room is null) diff --git a/DorkNet.Server/Data/DorkNetDbContext.cs b/DorkNet.Server/Data/DorkNetDbContext.cs index 9295088..7b92952 100644 --- a/DorkNet.Server/Data/DorkNetDbContext.cs +++ b/DorkNet.Server/Data/DorkNetDbContext.cs @@ -38,6 +38,8 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo public DbSet BugReports => Set(); public DbSet Clubs => Set(); public DbSet ClubMemberships => Set(); + public DbSet ClubRolePermissions => Set(); + public DbSet ClubAdditionalImages => Set(); public DbSet RoyaleMatches => Set(); public DbSet RoyaleMatchPlayers => Set(); public DbSet RoyalePlayerProgress => Set(); @@ -123,6 +125,21 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo e.HasIndex(s => new { s.PlayerId, s.Key }).IsUnique(); }); + // One permission row per (club, role) and one image per (club, slot); + // both are upserted by their PUT handlers, so the uniqueness is what + // keeps a repeated write from stacking duplicate rows. + modelBuilder.Entity(e => + { + e.HasKey(p => p.Id); + e.HasIndex(p => new { p.ClubId, p.MembershipType }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasKey(i => i.Id); + e.HasIndex(i => new { i.ClubId, i.Slot }).IsUnique(); + }); + modelBuilder.Entity(e => { e.HasKey(r => r.Id); diff --git a/DorkNet.Server/Data/Entities/ChatMessageEntity.cs b/DorkNet.Server/Data/Entities/ChatMessageEntity.cs index 29ae890..f00ad8f 100644 --- a/DorkNet.Server/Data/Entities/ChatMessageEntity.cs +++ b/DorkNet.Server/Data/Entities/ChatMessageEntity.cs @@ -30,6 +30,12 @@ public class ChatMessageEntity public DateTime SentAt { get; set; } = DateTime.UtcNow; + /// Per-message moderation state, set by + /// PUT thread/message/{id}/moderate (form field + /// moderationState). 0 = normal; a non-zero value means a moderator + /// has actioned the message and it should not be re-served verbatim. + public int ModerationState { get; set; } = 0; + /// Build the canonical DM thread key for two players. /// Order-independent so both sides hit the same row. public static string DmKey(long a, long b) => diff --git a/DorkNet.Server/Data/Entities/ClubEntity.cs b/DorkNet.Server/Data/Entities/ClubEntity.cs index a782e42..19d51a1 100644 --- a/DorkNet.Server/Data/Entities/ClubEntity.cs +++ b/DorkNet.Server/Data/Entities/ClubEntity.cs @@ -61,7 +61,9 @@ public class ClubEntity public int Visibility { get; set; } = 0; /// Club joinability enum on the wire (Joinability). - /// 0=Open, 1=RequestToJoin, 2=InviteOnly. + /// 0=Open, 1=InviteOnly, 2=AskToJoin — confirmed from the 2023 dump + /// (enum FEHIHCMDOLN). This comment previously had 1 and 2 swapped, which + /// is the reading the requesttojoin handler was fixed away from. public int Joinability { get; set; } = 0; /// Drives the wire AllowJuniors bool. True means @@ -83,6 +85,15 @@ public class ClubEntity /// browse. public int ClubType { get; set; } = 0; + /// Minimum account level required to join, surfaced as the wire + /// MinLevel and set by PUT club/{id}/minlevel. 0 = no gate. + public int MinLevel { get; set; } = 0; + + /// Whether the club's chat channel is available. Drives the wire + /// ClubChatEnabled and is the negation of what + /// GET club/{id}/hasDisabledClubChat answers. + public bool ClubChatEnabled { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/DorkNet.Server/Data/Entities/ClubRolePermissionEntity.cs b/DorkNet.Server/Data/Entities/ClubRolePermissionEntity.cs new file mode 100644 index 0000000..057321a --- /dev/null +++ b/DorkNet.Server/Data/Entities/ClubRolePermissionEntity.cs @@ -0,0 +1,65 @@ +using System.ComponentModel.DataAnnotations; + +namespace DorkNet.Server.Data.Entities; + +/// +/// What one club role is allowed to do, as set by +/// PUT club/{clubId}/permissions/{membershipType}. +/// +/// The 2023-03-21 client sends six booleans for a given role +/// (RecNet.Runtime/IKMMOCKDKAF_NestedType_BOIMHOCCOEI.txt:173, :189, :205, +/// :221, :237, :253) and reads them back on the club details envelope as the +/// CoownerPermissions / ModeratorPermissions / +/// MemberPermissions objects. +/// +/// One row per (club, role). A club with no row for a role falls back to the +/// derived default policy in ClubsController.PermissionsForRole, so +/// existing clubs keep behaving exactly as they did before this table existed. +/// +/// is the WIRE enum value (Member=10, +/// Moderator=20, CoOwner=30), not the stored permission bitmask — the client +/// addresses roles by the wire value and so does the route. +/// +public class ClubRolePermissionEntity +{ + public long Id { get; set; } + + public long ClubId { get; set; } + + /// Wire membership-type enum: 10=Member, 20=Moderator, 30=CoOwner. + public int MembershipType { get; set; } + + public bool EditDetails { get; set; } + public bool ApproveMember { get; set; } + public bool CreateEvent { get; set; } + public bool PostAnnouncement { get; set; } + public bool EditPermissionSettings { get; set; } + public bool BanUnban { get; set; } + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + +/// +/// One image in a club's gallery, addressed by slot. +/// +/// The client sets a slot with PUT club/{clubId}/additionalimage/{slot} +/// and clears it with the DELETE twin, then reads the set back as the details +/// envelope's AdditionalImages rows +/// ({ImageName, Slot} — recnet-runtime-decomp/HIKCHBLAMLP.cs:9, :27). +/// Without this table the envelope could only ever send an empty array, so the +/// gallery rendered blank no matter what the creator uploaded. +/// +public class ClubAdditionalImageEntity +{ + public long Id { get; set; } + + public long ClubId { get; set; } + + /// Gallery position, taken from the route. + public int Slot { get; set; } + + [MaxLength(256)] + public string ImageName { get; set; } = string.Empty; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/PlaylistEntity.cs b/DorkNet.Server/Data/Entities/PlaylistEntity.cs index fdd42d5..6184c33 100644 --- a/DorkNet.Server/Data/Entities/PlaylistEntity.cs +++ b/DorkNet.Server/Data/Entities/PlaylistEntity.cs @@ -56,6 +56,25 @@ public class PlaylistEntity /// Total joins across all member rooms. public int VisitCount { get; set; } = 0; + /// Playlist accessibility enum as the client sends it + /// (0 = private, 1 = public). Previously the accessibility/restrictions/ + /// level-voting/warning mutations were acknowledged and thrown away, so a + /// creator could never actually publish a playlist or change its settings — + /// the wire always reported the hardcoded defaults back. + public int Accessibility { get; set; } = 0; + + public bool SupportsLevelVoting { get; set; } = false; + public bool SupportsJuniors { get; set; } = true; + public bool SupportsScreens { get; set; } = true; + public bool SupportsTeleportVR { get; set; } = true; + public bool SupportsWalkVR { get; set; } = true; + + /// Content-warning bitmask, plus the creator's free-text warning. + public int WarningMask { get; set; } = 0; + + [MaxLength(512)] + public string CustomWarning { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/DorkNet.Server/Data/Entities/PrivateInstanceEntity.cs b/DorkNet.Server/Data/Entities/PrivateInstanceEntity.cs index 9e3c3b5..4c882a8 100644 --- a/DorkNet.Server/Data/Entities/PrivateInstanceEntity.cs +++ b/DorkNet.Server/Data/Entities/PrivateInstanceEntity.cs @@ -38,5 +38,11 @@ public class PrivateInstanceEntity public int MaxCapacity { get; set; } = 8; [MaxLength(128)] public string Name { get; set; } = string.Empty; + /// Host-chosen room code, set via PUT + /// /roominstance/{id}/roomcode. Empty means "no custom code", in + /// which case the deterministic RoomCodeService.Generate value is + /// served instead. + [MaxLength(16)] public string RoomCode { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/DorkNet.Server/Data/Entities/TestCaseEntities.cs b/DorkNet.Server/Data/Entities/TestCaseEntities.cs index 28fe4ba..840d1ce 100644 --- a/DorkNet.Server/Data/Entities/TestCaseEntities.cs +++ b/DorkNet.Server/Data/Entities/TestCaseEntities.cs @@ -64,6 +64,10 @@ public class TestCaseEntity /// case belongs to (null = orphan / standalone). public uint? TestPassId { get; set; } + /// Append-only JSON array of QA comments posted through + /// POST api/testcasemanagement/v1/testcase/{id}/comment. + public string CommentsJson { get; set; } = "[]"; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/DorkNet.Server/Data/url-coverage.md b/DorkNet.Server/Data/url-coverage.md index 89ab964..37a3d1e 100644 --- a/DorkNet.Server/Data/url-coverage.md +++ b/DorkNet.Server/Data/url-coverage.md @@ -161,6 +161,7 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`, | GET | `api/inventions/v3/saved` | REAL | `InventionsController.Saved` — caller's own creations (SAVED list) | | POST | `api/inventions/v3/save` | REAL | `InventionsController.Save` — creates a new `InventionEntity` (initial save) | | POST | `api/inventions/v3/addversion` | REAL | `InventionsController.AddVersion` — appends `InventionVersionEntity`, bumps `CurrentVersionNumber` | +| POST | `remote-run/push-to-studio` | REAL | `InventionsController.PushToStudio` (also `roomserver/` alias) — Rec Room Studio remote-run handoff. Raw-JSON body `{SessionId, RoomId, SubRoomId, UnityAssetId, RoomData:{Filename,Hash,OwnershipProof}, SubRoomData:{…}, SavedByAccountId}`; stamps the pushed `RoomDataBlobEntity` with its room/sub-room, records `UnityAssetId` on the `RoomScene`, sets `Rooms.StudioSessionId`, keeps the push under `remoterun:{SessionId}` in `PlayerSettings`. Does NOT commit the live save (that stays `rooms/{id}/subrooms/{id}/data`). Replies with the flattened `RemoteRunDetails` | ### `api/Leaderboard/` diff --git a/DorkNet.Server/Hubs/NotifyHub.cs b/DorkNet.Server/Hubs/NotifyHub.cs index 273ee3a..1258bce 100644 --- a/DorkNet.Server/Hubs/NotifyHub.cs +++ b/DorkNet.Server/Hubs/NotifyHub.cs @@ -40,6 +40,7 @@ namespace DorkNet.Server.Hubs; [Authorize] public class NotifyHub( OnlinePresenceService presence, + PlayerPresenceService playerRooms, NotificationService notifications, DorkNetDbContext db, ServerSettingsService serverSettings, @@ -95,10 +96,30 @@ public class NotifyHub( } await Groups.AddToGroupAsync(Context.ConnectionId, GroupForPlayer(id)); - await presence.AddConnectionAsync(id, Context.ConnectionId); + var cameOnline = await presence.AddConnectionAsync(id, Context.ConnectionId); logger.LogInformation( - "[hub] connected player={PlayerId} conn={ConnectionId} group={Group}", - id, Context.ConnectionId, GroupForPlayer(id)); + "[hub] connected player={PlayerId} conn={ConnectionId} group={Group} cameOnline={CameOnline}", + id, Context.ConnectionId, GroupForPlayer(id), cameOnline); + + // Tell friends they're back. OnDisconnectedAsync pushes + // PlayerWentOffline, but nothing used to push the opposite + // transition, so the pair was asymmetric: a SignalR reconnect + // (network blip, replica restart) greyed the player out on every + // friend's list for the rest of the session even though they were + // still playing. + // + // Only push when a room is already known. The presence payload + // derives isOnline from roomInstance being non-null, so a push with + // no room reads as OFFLINE — worse than staying quiet. On a fresh + // boot there is no room yet and the /goto that follows sends the + // real presence anyway; this branch is for reconnects, where the + // cached room is exactly what friends need to see again. + if (cameOnline && playerRooms.GetRoom(id) is { } room) + { + var friendIds = await RelationshipQueries.EffectiveFriendIdsAsync(db, serverSettings, id); + if (friendIds.Count > 0) + await notifications.PlayerCameOnline(friendIds, id, room); + } } else { diff --git a/DorkNet.Server/Migrations/20260727200252_Client2023WireFixes.Designer.cs b/DorkNet.Server/Migrations/20260727200252_Client2023WireFixes.Designer.cs new file mode 100644 index 0000000..3faefff --- /dev/null +++ b/DorkNet.Server/Migrations/20260727200252_Client2023WireFixes.Designer.cs @@ -0,0 +1,3572 @@ +// +using System; +using DorkNet.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + [DbContext(typeof(DorkNetDbContext))] + [Migration("20260727200252_Client2023WireFixes")] + partial class Client2023WireFixes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomAvatarItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("OutfitSelectionsV2") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBuiltIn") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WordsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CharadesWordLists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey") + .IsUnique(); + + b.ToTable("ChatThreads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("LastReadMessageId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SnoozeUntil") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ThreadKey", "PlayerId") + .IsUnique(); + + b.ToTable("ChatThreadMembers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "CreatedAt"); + + b.ToTable("ClubAnnouncements"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AnnouncementId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("AnnouncementId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubAnnouncementReads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CategoryTagId") + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CategoryTagId"); + + b.HasIndex("ClubId", "CategoryTagId") + .IsUnique(); + + b.ToTable("ClubCategoryAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("ClubCategoryTags"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowJuniors") + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ClubType") + .HasColumnType("INTEGER"); + + b.Property("ClubhouseRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsRRO") + .HasColumnType("INTEGER"); + + b.Property("Joinability") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SubscribedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("PlayerId", "ClubId") + .IsUnique(); + + b.ToTable("ClubSubscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssetName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BaseAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("PurchaseCount") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsFeatured"); + + b.HasIndex("IsPublic"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("CustomAvatarItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("CustomAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CustomAvatarItemId"); + + b.HasIndex("PlayerId", "CustomAvatarItemId") + .IsUnique(); + + b.ToTable("CustomAvatarItemOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GrantedItemId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Offer1ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer2ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer3ItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.Property("SelectedAt") + .HasColumnType("TEXT"); + + b.Property("SelectedGiftDropId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SelectedAt"); + + b.ToTable("GameRewardSelections"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SourceStoreItemId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "ItemKey", "ItemType") + .IsUnique(); + + b.ToTable("ItemWishlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("EarnedAt") + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "EventKey"); + + b.ToTable("Keepsakes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b => + { + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LowerIsBetter") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueFormat") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RoomId", "Channel"); + + b.HasIndex("RoomId"); + + b.ToTable("LeaderboardChannelMeta"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("RoomId", "StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PlatformMask") + .HasColumnType("INTEGER"); + + b.Property("RoomNamesCsv") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LoadingScreenTips"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastIp") + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PendingDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCommunityTeam") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CustomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsCurated") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("SupportsJuniors") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.Property("WarningMask") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsCurated"); + + b.HasIndex("Name"); + + b.ToTable("Playlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Cheered") + .HasColumnType("INTEGER"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlaylistId", "PlayerId") + .IsUnique(); + + b.ToTable("PlaylistInteractions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlaylistId"); + + b.HasIndex("RoomId"); + + b.HasIndex("PlaylistId", "OrderIndex"); + + b.ToTable("PlaylistRooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.Property("LatestInviteMessageId") + .HasColumnType("INTEGER"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyId") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomConsumables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyCode") + .HasColumnType("TEXT"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomConsumableId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomConsumableId"); + + b.HasIndex("PlayerId", "RoomConsumableId") + .IsUnique(); + + b.ToTable("RoomConsumableOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomCurrencyId"); + + b.HasIndex("PlayerId", "RoomCurrencyId") + .IsUnique(); + + b.ToTable("RoomCurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("DailyLimit") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomCurrencies"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomCurrencyId"); + + b.ToTable("RoomCurrencyPurchaseOffers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowNewUsers") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HiddenFromBrowse") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("IsRoomLinkedToRecRoomStudio") + .HasColumnType("INTEGER"); + + b.Property("IsStudioRoom") + .HasColumnType("INTEGER"); + + b.Property("LoadScreensJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayerCalculationMode") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("PromoExternalContentJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PromoImagesJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("StudioSessionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(174) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "Name"); + + b.ToTable("RoomKeys"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PaidPrice") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PurchasedAt") + .HasColumnType("TEXT"); + + b.Property("RoomKeyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomKeyId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomKeyPurchases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accepted") + .HasColumnType("INTEGER"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "PlayerId", "Role") + .IsUnique(); + + b.ToTable("RoomRoles"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StudioAssetBundleNamesCsv") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("StudioSubRoomDataSaveId") + .HasColumnType("INTEGER"); + + b.Property("StudioUnityAssetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("AllAvatarItemsOwned") + .HasColumnType("INTEGER"); + + b.Property("BaseRoomNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CharadesSlotBindingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscoveredGameConfigsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("GlobalFriendsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PlayMenuTagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ProfanityFilterDisabled") + .HasColumnType("INTEGER"); + + b.Property("RecCenterDoorsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("RoomBlobVersionClampDisabled") + .HasColumnType("INTEGER"); + + b.Property("SignupsDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WeeklyChallengeRewardJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WeeklyChallengesCompletedRequired") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("WeeklyChallengesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.HasKey("Id"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Descriptor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SignupCodes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("UgcPurchasables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260727200252_Client2023WireFixes.cs b/DorkNet.Server/Migrations/20260727200252_Client2023WireFixes.cs new file mode 100644 index 0000000..e2ffc18 --- /dev/null +++ b/DorkNet.Server/Migrations/20260727200252_Client2023WireFixes.cs @@ -0,0 +1,132 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + /// Columns needed by the March-2023 client wire fixes: the playlist + /// settings the client can set (accessibility, level voting, VR/junior + /// restrictions, content warning) which used to be acknowledged and thrown + /// away, plus a host-chosen room code on private instances. + /// + /// Hand-trimmed after generation: `migrations add` diffs against the + /// committed model snapshot, which already lagged the entity model, so the + /// generated file also carried unrelated pre-existing drift + /// (ServerSettings, Rooms, Avatars, ...). Replaying that against a database + /// where those columns already exist fails on the first duplicate + /// ADD COLUMN, so only the columns this change introduces are kept. + /// + public partial class Client2023WireFixes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RoomCode", + table: "PrivateInstances", + type: "TEXT", + maxLength: 16, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Accessibility", + table: "Playlists", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "CustomWarning", + table: "Playlists", + type: "TEXT", + maxLength: 512, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "SupportsJuniors", + table: "Playlists", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "SupportsLevelVoting", + table: "Playlists", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SupportsScreens", + table: "Playlists", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "SupportsTeleportVR", + table: "Playlists", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "SupportsWalkVR", + table: "Playlists", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "WarningMask", + table: "Playlists", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RoomCode", + table: "PrivateInstances"); + + migrationBuilder.DropColumn( + name: "Accessibility", + table: "Playlists"); + + migrationBuilder.DropColumn( + name: "CustomWarning", + table: "Playlists"); + + migrationBuilder.DropColumn( + name: "SupportsJuniors", + table: "Playlists"); + + migrationBuilder.DropColumn( + name: "SupportsLevelVoting", + table: "Playlists"); + + migrationBuilder.DropColumn( + name: "SupportsScreens", + table: "Playlists"); + + migrationBuilder.DropColumn( + name: "SupportsTeleportVR", + table: "Playlists"); + + migrationBuilder.DropColumn( + name: "SupportsWalkVR", + table: "Playlists"); + + migrationBuilder.DropColumn( + name: "WarningMask", + table: "Playlists"); + } + } +} diff --git a/DorkNet.Server/Migrations/20260728061658_ClubPermissionsGalleryAndModeration.Designer.cs b/DorkNet.Server/Migrations/20260728061658_ClubPermissionsGalleryAndModeration.Designer.cs new file mode 100644 index 0000000..c6e8968 --- /dev/null +++ b/DorkNet.Server/Migrations/20260728061658_ClubPermissionsGalleryAndModeration.Designer.cs @@ -0,0 +1,3654 @@ +// +using System; +using DorkNet.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + [DbContext(typeof(DorkNetDbContext))] + [Migration("20260728061658_ClubPermissionsGalleryAndModeration")] + partial class ClubPermissionsGalleryAndModeration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AdminActionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AdminPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdminPlayerId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("TargetType", "TargetId"); + + b.ToTable("AdminActions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomAvatarItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EquippedItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FaceFeatures") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("HairColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("InventoryJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutfitSelections") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("OutfitSelectionsV2") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SavedOutfitsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SkinColor") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("Avatars"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.BugReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ClientVersion") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ReadAt"); + + b.HasIndex("ReporterPlayerId"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CardEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("PlayerId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBuiltIn") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WordsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CharadesWordLists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ModerationState") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey", "SentAt"); + + b.ToTable("ChatMessages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ThreadKey") + .IsUnique(); + + b.ToTable("ChatThreads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatThreadMemberEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("LastReadMessageId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SnoozeUntil") + .HasColumnType("TEXT"); + + b.Property("ThreadKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ThreadKey", "PlayerId") + .IsUnique(); + + b.ToTable("ChatThreadMembers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CheerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheeredAt") + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPhotoId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TargetPhotoId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("TargetRoomId"); + + b.HasIndex("FromPlayerId", "TargetPlayerId", "TargetRoomId", "TargetPhotoId", "Type") + .IsUnique(); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Slot") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "Slot") + .IsUnique(); + + b.ToTable("ClubAdditionalImages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "CreatedAt"); + + b.ToTable("ClubAnnouncements"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementReadEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AnnouncementId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("AnnouncementId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubAnnouncementReads"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CategoryTagId") + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CategoryTagId"); + + b.HasIndex("ClubId", "CategoryTagId") + .IsUnique(); + + b.ToTable("ClubCategoryAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubCategoryTagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("ClubCategoryTags"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowJuniors") + .HasColumnType("INTEGER"); + + b.Property("BanStatus") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ClubChatEnabled") + .HasColumnType("INTEGER"); + + b.Property("ClubType") + .HasColumnType("INTEGER"); + + b.Property("ClubhouseRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsRRO") + .HasColumnType("INTEGER"); + + b.Property("Joinability") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Clubs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubMembershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("ClubId", "PlayerId") + .IsUnique(); + + b.ToTable("ClubMemberships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApproveMember") + .HasColumnType("INTEGER"); + + b.Property("BanUnban") + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CreateEvent") + .HasColumnType("INTEGER"); + + b.Property("EditDetails") + .HasColumnType("INTEGER"); + + b.Property("EditPermissionSettings") + .HasColumnType("INTEGER"); + + b.Property("MembershipType") + .HasColumnType("INTEGER"); + + b.Property("PostAnnouncement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "MembershipType") + .IsUnique(); + + b.ToTable("ClubRolePermissions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("SubscribedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("PlayerId", "ClubId") + .IsUnique(); + + b.ToTable("ClubSubscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CohortAssignmentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.Property("CohortKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Variant") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CohortKey") + .IsUnique(); + + b.ToTable("CohortAssignments"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CommunityBoardEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Json") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CommunityBoardRows"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxRedemptions") + .HasColumnType("INTEGER"); + + b.Property("RedemptionCount") + .HasColumnType("INTEGER"); + + b.Property("RewardAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardCurrencyType") + .HasColumnType("INTEGER"); + + b.Property("RewardItemSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CouponRedemptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CouponId", "PlayerId") + .IsUnique(); + + b.ToTable("CouponRedemptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "CurrencyType") + .IsUnique(); + + b.ToTable("CurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssetName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BaseAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("PurchaseCount") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsFeatured"); + + b.HasIndex("IsPublic"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("CustomAvatarItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("CustomAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CustomAvatarItemId"); + + b.HasIndex("PlayerId", "CustomAvatarItemId") + .IsUnique(); + + b.ToTable("CustomAvatarItemOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => + { + b.Property("PlayerId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("PlayerId"); + + b.ToTable("DormStates"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameRewardSelectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GrantedItemId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Offer1ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer2ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer3ItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RewardType") + .HasColumnType("INTEGER"); + + b.Property("SelectedAt") + .HasColumnType("TEXT"); + + b.Property("SelectedGiftDropId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SelectedAt"); + + b.ToTable("GameRewardSelections"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GameSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityLevelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("PhotonRoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerCount") + .HasColumnType("INTEGER"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Region"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.GiftPackageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvatarItemDescOrHairDyeDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("AvatarItemType") + .HasColumnType("INTEGER"); + + b.Property("ConsumableItemDesc") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Consumed") + .HasColumnType("INTEGER"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("EquipmentModificationGuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("EquipmentPrefabName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("FromPlayerId") + .HasColumnType("INTEGER"); + + b.Property("GiftContext") + .HasColumnType("INTEGER"); + + b.Property("GiftRarity") + .HasColumnType("INTEGER"); + + b.Property("IsGifted") + .HasColumnType("INTEGER"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PackageMaterial") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PackageVariant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SourceStoreItemId") + .HasColumnType("INTEGER"); + + b.Property("SupportsCurrentPlatform") + .HasColumnType("INTEGER"); + + b.Property("Xp") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "Consumed"); + + b.ToTable("GiftPackages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationRoomId") + .HasColumnType("INTEGER"); + + b.Property("CreatorPermission") + .HasColumnType("INTEGER"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CurrentVersionNumber") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FirstPublishedAt") + .HasColumnType("TEXT"); + + b.Property("GeneralPermission") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAgInvention") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NumPlayersHaveUsedInRoom") + .HasColumnType("INTEGER"); + + b.Property("Permission") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SpawnCount") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheerCount"); + + b.HasIndex("CreatorPlayerId"); + + b.ToTable("Inventions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.InventionVersionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstantiationCost") + .HasColumnType("INTEGER"); + + b.Property("InventionId") + .HasColumnType("INTEGER"); + + b.Property("LightsCost") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("VersionNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InventionId", "VersionNumber") + .IsUnique(); + + b.ToTable("InventionVersions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.IpBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedAt") + .HasColumnType("TEXT"); + + b.Property("BannedByAdminId") + .HasColumnType("INTEGER"); + + b.Property("Cidr") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Cidr"); + + b.ToTable("IpBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "ItemKey", "ItemType") + .IsUnique(); + + b.ToTable("ItemWishlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("EarnedAt") + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "EventKey"); + + b.ToTable("Keepsakes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b => + { + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Channel") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LowerIsBetter") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueFormat") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("RoomId", "Channel"); + + b.HasIndex("RoomId"); + + b.ToTable("LeaderboardChannelMeta"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardStatEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StatChannel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "PlayerId", "StatChannel") + .IsUnique(); + + b.HasIndex("RoomId", "StatChannel", "Value"); + + b.ToTable("LeaderboardStats"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.LoadingScreenTipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("PlatformMask") + .HasColumnType("INTEGER"); + + b.Property("RoomNamesCsv") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LoadingScreenTips"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.MessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("RecipientPlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SenderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RecipientPlayerId", "SentAt"); + + b.HasIndex("SenderPlayerId", "SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.NotificationPrefsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAll") + .HasColumnType("INTEGER"); + + b.Property("AllowAnnouncements") + .HasColumnType("INTEGER"); + + b.Property("AllowEventInvite") + .HasColumnType("INTEGER"); + + b.Property("AllowFriendRequest") + .HasColumnType("INTEGER"); + + b.Property("AllowMessage") + .HasColumnType("INTEGER"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("NotificationPrefs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ObjectiveProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClearedAt") + .HasColumnType("TEXT"); + + b.Property("IsCompleted") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("ObjectiveProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastIp") + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PendingDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Caption") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TaggedPlayerIdsCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("UploaderPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ViewCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RoomId"); + + b.HasIndex("UploaderPlayerId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlatformIgnoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Platform", "PlatformUserId") + .IsUnique(); + + b.ToTable("PlatformIgnores"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("FirstSeen") + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PlayerDevices"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEloEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Elo") + .HasColumnType("INTEGER"); + + b.Property("GameMode") + .HasColumnType("INTEGER"); + + b.Property("Losses") + .HasColumnType("INTEGER"); + + b.Property("MatchesPlayed") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Wins") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GameMode", "Elo"); + + b.HasIndex("PlayerId", "GameMode") + .IsUnique(); + + b.ToTable("PlayerElo"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BannedUntil") + .HasColumnType("TEXT"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Birthday") + .HasColumnType("TEXT"); + + b.Property("CanReceiveInvites") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedFromDeviceId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCommunityTeam") + .HasColumnType("INTEGER"); + + b.Property("IsDeveloper") + .HasColumnType("INTEGER"); + + b.Property("IsJunior") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastIp") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastPlatform") + .HasColumnType("INTEGER"); + + b.Property("LastPlatformId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProfileImageName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("XP") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Capacity") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndsAt") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("StartsAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("StartsAt"); + + b.ToTable("PlayerEvents"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEventResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Response") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("EventId", "PlayerId") + .IsUnique(); + + b.ToTable("PlayerEventResponses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerInventoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("ItemSlug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "ItemSlug") + .IsUnique(); + + b.ToTable("PlayerInventory"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "Key") + .IsUnique(); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CustomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsCurated") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("SupportsJuniors") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.Property("WarningMask") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsCurated"); + + b.HasIndex("Name"); + + b.ToTable("Playlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistInteractionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Cheered") + .HasColumnType("INTEGER"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlaylistId", "PlayerId") + .IsUnique(); + + b.ToTable("PlaylistInteractions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlaylistRoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("PlaylistId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlaylistId"); + + b.HasIndex("RoomId"); + + b.HasIndex("PlaylistId", "OrderIndex"); + + b.ToTable("PlaylistRooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataBlob") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PhotonRegion") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhotonRoomId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RoomCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerPlayerId"); + + b.HasIndex("RoomId", "SubRoomId"); + + b.ToTable("PrivateInstances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PrivateInstanceInviteeEntity", b => + { + b.Property("PrivateInstanceId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("InvitedAt") + .HasColumnType("TEXT"); + + b.Property("LatestInviteMessageId") + .HasColumnType("INTEGER"); + + b.HasKey("PrivateInstanceId", "PlayerId"); + + b.HasIndex("PlayerId"); + + b.ToTable("PrivateInstanceInvitees"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PushTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token"); + + b.HasIndex("PlayerId", "Platform") + .IsUnique(); + + b.ToTable("PushTokens"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Favorited") + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("Muted") + .HasColumnType("INTEGER"); + + b.Property("RequesterId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RequesterId", "TargetId") + .IsUnique(); + + b.ToTable("Relationships"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GameSessionId") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ReporterPlayerId") + .HasColumnType("INTEGER"); + + b.Property("ResolutionNote") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT"); + + b.Property("ResolverAdminId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("TargetEventId") + .HasColumnType("INTEGER"); + + b.Property("TargetInventionId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetRoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReporterPlayerId"); + + b.HasIndex("ResolvedAt"); + + b.HasIndex("TargetPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BanType") + .HasColumnType("INTEGER"); + + b.Property("BannedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("BannedPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("Until") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BannedPlayerId"); + + b.HasIndex("RoomId", "BannedPlayerId"); + + b.ToTable("RoomBans"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomBookmarkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "RoomId") + .IsUnique(); + + b.ToTable("RoomBookmarks"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyId") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomConsumables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyCode") + .HasColumnType("TEXT"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomConsumableId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomConsumableId"); + + b.HasIndex("PlayerId", "RoomConsumableId") + .IsUnique(); + + b.ToTable("RoomConsumableOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomCurrencyId"); + + b.HasIndex("PlayerId", "RoomCurrencyId") + .IsUnique(); + + b.ToTable("RoomCurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("DailyLimit") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomCurrencies"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomCurrencyId"); + + b.ToTable("RoomCurrencyPurchaseOffers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Bytes") + .HasColumnType("BLOB"); + + b.Property("ReferencedFilenamesCsv") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SubRoomId") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploadedByPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BlobName") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomDataBlobs"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accessibility") + .HasColumnType("INTEGER"); + + b.Property("AllowNewUsers") + .HasColumnType("INTEGER"); + + b.Property("AllowsJuniors") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("CloningAllowed") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrentDataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CustomRoomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DisableMicAutoMute") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("HiddenFromBrowse") + .HasColumnType("INTEGER"); + + b.Property("HotScore") + .HasColumnType("REAL"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAGRoom") + .HasColumnType("INTEGER"); + + b.Property("IsDormRoom") + .HasColumnType("INTEGER"); + + b.Property("IsRoomLinkedToRecRoomStudio") + .HasColumnType("INTEGER"); + + b.Property("IsStudioRoom") + .HasColumnType("INTEGER"); + + b.Property("LoadScreensJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("LocationReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayerCalculationMode") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("PromoExternalContentJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PromoImagesJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("RoomWarningMask") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("StudioSessionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsMobile") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsVRLow") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + + b.Property("TagsCsv") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.Property("VisitorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("HotScore"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(174) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("ReplicationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "Name"); + + b.ToTable("RoomKeys"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomKeyPurchaseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PaidPrice") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("PurchasedAt") + .HasColumnType("TEXT"); + + b.Property("RoomKeyId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomKeyId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomKeyPurchases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomRoleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Accepted") + .HasColumnType("INTEGER"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.HasIndex("RoomId", "PlayerId", "Role") + .IsUnique(); + + b.ToTable("RoomRoles"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomSceneEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanMatchmakeInto") + .HasColumnType("INTEGER"); + + b.Property("DataBlobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DataModifiedAt") + .HasColumnType("TEXT"); + + b.Property("IsSandbox") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("OrderIndex") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("RoomSceneLocationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StudioAssetBundleNamesCsv") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("StudioSubRoomDataSaveId") + .HasColumnType("INTEGER"); + + b.Property("StudioUnityAssetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Name"); + + b.HasIndex("RoomId", "OrderIndex") + .IsUnique(); + + b.ToTable("RoomScenes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomVisitEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstVisitAt") + .HasColumnType("TEXT"); + + b.Property("LastVisitAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("VisitCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId", "LastVisitAt"); + + b.HasIndex("RoomId", "PlayerId") + .IsUnique(); + + b.ToTable("RoomVisits"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChestsOpened") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGame") + .HasColumnType("INTEGER"); + + b.Property("HealthPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.Property("SecondsInAir") + .HasColumnType("INTEGER"); + + b.Property("ShieldPotionsConsumed") + .HasColumnType("INTEGER"); + + b.Property("WalkGame") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompletedAt"); + + b.ToTable("RoyaleMatches"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyaleMatchPlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MatchId") + .HasColumnType("INTEGER"); + + b.Property("NumEliminations") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.Property("SecondsAlive") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("MatchId", "PlayerId"); + + b.ToTable("RoyaleMatchPlayers"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoyalePlayerProgressEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("NextLevelAcornReward") + .HasColumnType("INTEGER"); + + b.Property("NextLevelXPThreshold") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RankIdx") + .HasColumnType("INTEGER"); + + b.Property("RankName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TotalXP") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("RoyalePlayerProgress"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.ServerSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("AllAvatarItemsOwned") + .HasColumnType("INTEGER"); + + b.Property("BaseRoomNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CharadesSlotBindingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscoveredGameConfigsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("GlobalFriendsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PlayMenuTagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ProfanityFilterDisabled") + .HasColumnType("INTEGER"); + + b.Property("RecCenterDoorsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("RoomBlobVersionClampDisabled") + .HasColumnType("INTEGER"); + + b.Property("SignupsDisabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WeeklyChallengeRewardJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WeeklyChallengesCompletedRequired") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("WeeklyChallengesJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.HasKey("Id"); + + b.ToTable("ServerSettings"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Descriptor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SignupCodes"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvailableUntil") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsLimitedTime") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Storefront") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Storefront"); + + b.HasIndex("IsActive", "Category"); + + b.ToTable("StoreItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.SubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("SubscriberPlayerId") + .HasColumnType("INTEGER"); + + b.Property("TargetPlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SubscriberPlayerId"); + + b.HasIndex("TargetPlayerId"); + + b.HasIndex("SubscriberPlayerId", "TargetPlayerId") + .IsUnique(); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestCaseEntity", b => + { + b.Property("Pk") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssignedPlayerIdsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssignedPlayerNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CommentsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JiraBugUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("JiraUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("MinNumAssignedPlayers") + .HasColumnType("INTEGER"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TestPassId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Pk"); + + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TestPassId"); + + b.ToTable("TestCases"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.TestPassEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TagsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WasManuallyClosed") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TestPasses"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("UgcPurchasables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithOne("Avatar") + .HasForeignKey("DorkNet.Server.Data.Entities.AvatarEntity", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerSettingEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") + .WithMany("Settings") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RelationshipEntity", b => + { + b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Requester") + .WithMany("Relationships") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.PlayerEntity", b => + { + b.Navigation("Avatar"); + + b.Navigation("Relationships"); + + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DorkNet.Server/Migrations/20260728061658_ClubPermissionsGalleryAndModeration.cs b/DorkNet.Server/Migrations/20260728061658_ClubPermissionsGalleryAndModeration.cs new file mode 100644 index 0000000..c8f0987 --- /dev/null +++ b/DorkNet.Server/Migrations/20260728061658_ClubPermissionsGalleryAndModeration.cs @@ -0,0 +1,118 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DorkNet.Server.Migrations +{ + /// + public partial class ClubPermissionsGalleryAndModeration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CommentsJson", + table: "TestCases", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ClubChatEnabled", + table: "Clubs", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MinLevel", + table: "Clubs", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ModerationState", + table: "ChatMessages", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "ClubAdditionalImages", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ClubId = table.Column(type: "INTEGER", nullable: false), + Slot = table.Column(type: "INTEGER", nullable: false), + ImageName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClubAdditionalImages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ClubRolePermissions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ClubId = table.Column(type: "INTEGER", nullable: false), + MembershipType = table.Column(type: "INTEGER", nullable: false), + EditDetails = table.Column(type: "INTEGER", nullable: false), + ApproveMember = table.Column(type: "INTEGER", nullable: false), + CreateEvent = table.Column(type: "INTEGER", nullable: false), + PostAnnouncement = table.Column(type: "INTEGER", nullable: false), + EditPermissionSettings = table.Column(type: "INTEGER", nullable: false), + BanUnban = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClubRolePermissions", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ClubAdditionalImages_ClubId_Slot", + table: "ClubAdditionalImages", + columns: new[] { "ClubId", "Slot" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ClubRolePermissions_ClubId_MembershipType", + table: "ClubRolePermissions", + columns: new[] { "ClubId", "MembershipType" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ClubAdditionalImages"); + + migrationBuilder.DropTable( + name: "ClubRolePermissions"); + + migrationBuilder.DropColumn( + name: "CommentsJson", + table: "TestCases"); + + migrationBuilder.DropColumn( + name: "ClubChatEnabled", + table: "Clubs"); + + migrationBuilder.DropColumn( + name: "MinLevel", + table: "Clubs"); + + migrationBuilder.DropColumn( + name: "ModerationState", + table: "ChatMessages"); + } + } +} diff --git a/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs b/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs index 3591d34..59d7102 100644 --- a/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs +++ b/DorkNet.Server/Migrations/DorkNetDbContextModelSnapshot.cs @@ -64,6 +64,10 @@ namespace DorkNet.Server.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("CustomAvatarItemsJson") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("EquippedItemsJson") .IsRequired() .HasColumnType("TEXT"); @@ -87,6 +91,11 @@ namespace DorkNet.Server.Migrations .HasMaxLength(2048) .HasColumnType("TEXT"); + b.Property("OutfitSelectionsV2") + .IsRequired() + .HasMaxLength(8192) + .HasColumnType("TEXT"); + b.Property("PlayerId") .HasColumnType("INTEGER"); @@ -214,6 +223,34 @@ namespace DorkNet.Server.Migrations b.ToTable("Cards"); }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.CharadesWordListEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBuiltIn") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WordsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CharadesWordLists"); + }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.ChatMessageEntity", b => { b.Property("Id") @@ -225,6 +262,9 @@ namespace DorkNet.Server.Migrations .HasMaxLength(2000) .HasColumnType("TEXT"); + b.Property("ModerationState") + .HasColumnType("INTEGER"); + b.Property("SenderPlayerId") .HasColumnType("INTEGER"); @@ -349,6 +389,34 @@ namespace DorkNet.Server.Migrations b.ToTable("Cheers"); }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAdditionalImageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Slot") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "Slot") + .IsUnique(); + + b.ToTable("ClubAdditionalImages"); + }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubAnnouncementEntity", b => { b.Property("Id") @@ -477,6 +545,9 @@ namespace DorkNet.Server.Migrations .HasMaxLength(64) .HasColumnType("TEXT"); + b.Property("ClubChatEnabled") + .HasColumnType("INTEGER"); + b.Property("ClubType") .HasColumnType("INTEGER"); @@ -505,6 +576,9 @@ namespace DorkNet.Server.Migrations b.Property("Joinability") .HasColumnType("INTEGER"); + b.Property("MinLevel") + .HasColumnType("INTEGER"); + b.Property("Name") .IsRequired() .HasMaxLength(128) @@ -558,6 +632,47 @@ namespace DorkNet.Server.Migrations b.ToTable("ClubMemberships"); }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubRolePermissionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApproveMember") + .HasColumnType("INTEGER"); + + b.Property("BanUnban") + .HasColumnType("INTEGER"); + + b.Property("ClubId") + .HasColumnType("INTEGER"); + + b.Property("CreateEvent") + .HasColumnType("INTEGER"); + + b.Property("EditDetails") + .HasColumnType("INTEGER"); + + b.Property("EditPermissionSettings") + .HasColumnType("INTEGER"); + + b.Property("MembershipType") + .HasColumnType("INTEGER"); + + b.Property("PostAnnouncement") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClubId", "MembershipType") + .IsUnique(); + + b.ToTable("ClubRolePermissions"); + }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.ClubSubscriptionEntity", b => { b.Property("Id") @@ -732,6 +847,109 @@ namespace DorkNet.Server.Migrations b.ToTable("CurrencyBalances"); }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssetName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BaseAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("CheerCount") + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("PurchaseCount") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("IsFeatured"); + + b.HasIndex("IsPublic"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.ToTable("CustomAvatarItems"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.CustomAvatarItemOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("CustomAvatarItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CustomAvatarItemId"); + + b.HasIndex("PlayerId", "CustomAvatarItemId") + .IsUnique(); + + b.ToTable("CustomAvatarItemOwnership"); + }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.DormStateEntity", b => { b.Property("PlayerId") @@ -766,11 +984,23 @@ namespace DorkNet.Server.Migrations b.Property("GiftContext") .HasColumnType("INTEGER"); + b.Property("GrantedItemId") + .HasColumnType("INTEGER"); + b.Property("Message") .IsRequired() .HasMaxLength(256) .HasColumnType("TEXT"); + b.Property("Offer1ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer2ItemId") + .HasColumnType("INTEGER"); + + b.Property("Offer3ItemId") + .HasColumnType("INTEGER"); + b.Property("PlayerId") .HasColumnType("INTEGER"); @@ -920,6 +1150,9 @@ namespace DorkNet.Server.Migrations b.Property("RecipientPlayerId") .HasColumnType("INTEGER"); + b.Property("SourceStoreItemId") + .HasColumnType("INTEGER"); + b.Property("SupportsCurrentPlatform") .HasColumnType("INTEGER"); @@ -998,6 +1231,9 @@ namespace DorkNet.Server.Migrations b.Property("Permission") .HasColumnType("INTEGER"); + b.Property("Price") + .HasColumnType("INTEGER"); + b.Property("ReplicationId") .IsRequired() .HasMaxLength(64) @@ -1094,8 +1330,85 @@ namespace DorkNet.Server.Migrations b.ToTable("IpBans"); }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.ItemWishlistEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "ItemKey", "ItemType") + .IsUnique(); + + b.ToTable("ItemWishlists"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.KeepsakeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("EarnedAt") + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId", "EventKey"); + + b.ToTable("Keepsakes"); + }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.LeaderboardChannelMetaEntity", b => { + b.Property("RoomId") + .HasColumnType("INTEGER"); + b.Property("Channel") .HasColumnType("INTEGER"); @@ -1109,9 +1422,6 @@ namespace DorkNet.Server.Migrations .IsRequired() .HasColumnType("TEXT"); - b.Property("RoomId") - .HasColumnType("INTEGER"); - b.Property("UpdatedAt") .HasColumnType("TEXT"); @@ -1312,6 +1622,37 @@ namespace DorkNet.Server.Migrations b.ToTable("ObjectiveProgress"); }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.PendingDeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstSeenAt") + .HasColumnType("TEXT"); + + b.Property("LastIp") + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.Property("PlatformId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PendingDevices"); + }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.PhotoEntity", b => { b.Property("Id") @@ -1718,6 +2059,9 @@ namespace DorkNet.Server.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("Accessibility") + .HasColumnType("INTEGER"); + b.Property("CheerCount") .HasColumnType("INTEGER"); @@ -1727,6 +2071,11 @@ namespace DorkNet.Server.Migrations b.Property("CreatorPlayerId") .HasColumnType("INTEGER"); + b.Property("CustomWarning") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + b.Property("Description") .IsRequired() .HasMaxLength(2000) @@ -1752,6 +2101,21 @@ namespace DorkNet.Server.Migrations b.Property("OrderIndex") .HasColumnType("INTEGER"); + b.Property("SupportsJuniors") + .HasColumnType("INTEGER"); + + b.Property("SupportsLevelVoting") + .HasColumnType("INTEGER"); + + b.Property("SupportsScreens") + .HasColumnType("INTEGER"); + + b.Property("SupportsTeleportVR") + .HasColumnType("INTEGER"); + + b.Property("SupportsWalkVR") + .HasColumnType("INTEGER"); + b.Property("TagsCsv") .IsRequired() .HasMaxLength(1024) @@ -1766,6 +2130,9 @@ namespace DorkNet.Server.Migrations b.Property("VisitorCount") .HasColumnType("INTEGER"); + b.Property("WarningMask") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("CreatorPlayerId"); @@ -1873,6 +2240,11 @@ namespace DorkNet.Server.Migrations .HasMaxLength(256) .HasColumnType("TEXT"); + b.Property("RoomCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + b.Property("RoomId") .HasColumnType("INTEGER"); @@ -2105,6 +2477,221 @@ namespace DorkNet.Server.Migrations b.ToTable("RoomBookmarks"); }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyId") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomConsumables"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomConsumableOwnershipEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyCode") + .HasColumnType("TEXT"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomConsumableId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoomConsumableId"); + + b.HasIndex("PlayerId", "RoomConsumableId") + .IsUnique(); + + b.ToTable("RoomConsumableOwnership"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyBalanceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Balance") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("INTEGER"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoomCurrencyId"); + + b.HasIndex("PlayerId", "RoomCurrencyId") + .IsUnique(); + + b.ToTable("RoomCurrencyBalances"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("DailyLimit") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("RoomCurrencies"); + }); + + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomCurrencyPurchaseOfferEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomCurrencyId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomCurrencyId"); + + b.ToTable("RoomCurrencyPurchaseOffers"); + }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.RoomDataBlobEntity", b => { b.Property("Id") @@ -2155,6 +2742,9 @@ namespace DorkNet.Server.Migrations b.Property("Accessibility") .HasColumnType("INTEGER"); + b.Property("AllowNewUsers") + .HasColumnType("INTEGER"); + b.Property("AllowsJuniors") .HasColumnType("INTEGER"); @@ -2208,23 +2798,58 @@ namespace DorkNet.Server.Migrations b.Property("IsDormRoom") .HasColumnType("INTEGER"); + b.Property("IsRoomLinkedToRecRoomStudio") + .HasColumnType("INTEGER"); + + b.Property("IsStudioRoom") + .HasColumnType("INTEGER"); + + b.Property("LoadScreensJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + b.Property("LocationReplicationId") .IsRequired() .HasMaxLength(64) .HasColumnType("TEXT"); + b.Property("MaxCapacity") + .HasColumnType("INTEGER"); + + b.Property("MaxPlayerCalculationMode") + .HasColumnType("INTEGER"); + + b.Property("MinLevel") + .HasColumnType("INTEGER"); + b.Property("Name") .IsRequired() .HasMaxLength(128) .HasColumnType("TEXT") .UseCollation("NOCASE"); + b.Property("PromoExternalContentJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PromoImagesJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + b.Property("RoomWarningMask") .HasColumnType("INTEGER"); b.Property("State") .HasColumnType("INTEGER"); + b.Property("StudioSessionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + b.Property("SupportsLevelVoting") .HasColumnType("INTEGER"); @@ -2421,6 +3046,19 @@ namespace DorkNet.Server.Migrations .HasMaxLength(64) .HasColumnType("TEXT"); + b.Property("StudioAssetBundleNamesCsv") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("StudioSubRoomDataSaveId") + .HasColumnType("INTEGER"); + + b.Property("StudioUnityAssetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("RoomId", "Name"); @@ -2585,6 +3223,44 @@ namespace DorkNet.Server.Migrations b.Property("Id") .HasColumnType("INTEGER"); + b.Property("AllAvatarItemsOwned") + .HasColumnType("INTEGER"); + + b.Property("BaseRoomNamesJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CharadesSlotBindingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscoveredGameConfigsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("GlobalFriendsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PlayMenuTagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ProfanityFilterDisabled") + .HasColumnType("INTEGER"); + + b.Property("RecCenterDoorsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("RoomBlobVersionClampDisabled") + .HasColumnType("INTEGER"); + b.Property("SignupsDisabled") .HasColumnType("INTEGER"); @@ -2613,6 +3289,43 @@ namespace DorkNet.Server.Migrations b.ToTable("ServerSettings"); }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.SignupCodeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Descriptor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedAt") + .HasColumnType("TEXT"); + + b.Property("RedeemedByPlayerId") + .HasColumnType("INTEGER"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SignupCodes"); + }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.StoreItemEntity", b => { b.Property("Id") @@ -2723,6 +3436,10 @@ namespace DorkNet.Server.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("CommentsJson") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("CreatedAt") .HasColumnType("TEXT"); @@ -2821,6 +3538,72 @@ namespace DorkNet.Server.Migrations b.ToTable("TestPasses"); }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.UgcPurchasableEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorPlayerId") + .HasColumnType("INTEGER"); + + b.Property("CurrencyType") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ImageName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsFeatured") + .HasColumnType("INTEGER"); + + b.Property("ItemType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("INTEGER"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RoomId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorPlayerId"); + + b.HasIndex("PublicId") + .IsUnique(); + + b.HasIndex("RoomId"); + + b.ToTable("UgcPurchasables"); + }); + modelBuilder.Entity("DorkNet.Server.Data.Entities.AvatarEntity", b => { b.HasOne("DorkNet.Server.Data.Entities.PlayerEntity", "Player") diff --git a/DorkNet.Server/Services/ClubService.cs b/DorkNet.Server/Services/ClubService.cs index 4292f31..6ea71c5 100644 --- a/DorkNet.Server/Services/ClubService.cs +++ b/DorkNet.Server/Services/ClubService.cs @@ -185,19 +185,41 @@ public class ClubService(DorkNetDbContext db) .ToList(); } + /// + /// Per-player settings key holding the explicitly chosen home club. + /// is the server's general-purpose + /// per-player key/value bag (the same store MatchPlayerController + /// and RoomsController use for stateful scalars), so the 2023 + /// client's PUT/DELETE club/home/me pair persists here rather + /// than needing a new column on . + /// + public const string HomeClubSettingKey = "club.home"; + /// /// Composite home-tab payload for GET /club/home/me. The /// watch's deserialiser for this endpoint expects a single /// RecNet.Club object (typeof(System.Action`1<PLILLKHMNDA>) /// per ISIL) — typically the player's "home club" (set via - /// SetMyHomeClub) rendered as the headline card. We pick the - /// first club the player is a member of, ordered by membership - /// recency. Returns null when the player has no club memberships - /// — the controller turns that into - /// so the watch knows to render the "join a club" empty state. + /// SetMyHomeClub) rendered as the headline card. An explicit + /// pick made through always wins; when + /// nothing is stored (or the stored club has since been disbanded) we + /// fall back to the most recently joined club so the tab is never + /// blank for an existing member. Returns null when the player has no + /// club memberships at all — the controller turns that into the + /// ClubId=0 sentinel the watch renders as "no home club". /// public async Task HomeClubAsync(long playerId) { + var stored = await db.PlayerSettings.AsNoTracking() + .Where(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey) + .Select(s => s.Value) + .FirstOrDefaultAsync(); + if (long.TryParse(stored, out var storedId) && storedId > 0) + { + var chosen = await db.Clubs.FirstOrDefaultAsync(c => c.Id == storedId && c.State == 0); + if (chosen is not null) return chosen; + } + // Most-recent-joined club wins. Owners are members too (the // /create flow inserts an owner-permissions=127 membership // row), so a brand-new club creator sees their fresh club here. @@ -209,6 +231,112 @@ public class ClubService(DorkNetDbContext db) return pick; } + /// Persist the player's explicit home-club pick. Returns false + /// when the club id doesn't resolve to a live club so the caller can 404 + /// instead of storing a dangling id. + public async Task SetHomeClubAsync(long playerId, long clubId) + { + if (!await db.Clubs.AnyAsync(c => c.Id == clubId && c.State == 0)) return false; + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey); + if (row is null) + { + db.PlayerSettings.Add(new PlayerSettingEntity + { + PlayerId = playerId, + Key = HomeClubSettingKey, + Value = clubId.ToString(), + }); + } + else + { + row.Value = clubId.ToString(); + } + await db.SaveChangesAsync(); + return true; + } + + /// Drop the player's explicit home-club pick. Idempotent — + /// missing row is a no-op, matching the client's fire-and-forget + /// "Remove Home Clubhouse Room" call. + public async Task ClearHomeClubAsync(long playerId) + { + var row = await db.PlayerSettings + .FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey); + if (row is null) return; + db.PlayerSettings.Remove(row); + await db.SaveChangesAsync(); + } + + /// + /// Disband a club — the client's "Delete Club" action. Owner-only + /// (unlike , which co-owners and moderators + /// can drive), and a soft delete: State is stamped with the client's + /// IACDINKNHKB.MarkedForDelete = 1000 so every read path here + /// (all of which filter State == 0) stops surfacing it while + /// announcements / memberships stay intact for moderation review. + /// Returns null when the club doesn't exist; throws + /// when the caller isn't the + /// creator. + /// + public async Task DisbandAsync(long clubId, long callerId) + { + var club = await db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId && c.State == 0); + if (club is null) return null; + if (club.CreatorPlayerId != callerId) throw new UnauthorizedAccessException(); + club.State = 1000; // IACDINKNHKB.MarkedForDelete + club.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return club; + } + + /// Every announcement across the clubs the player is a real + /// member of (pending invites/requests = 128 and ban markers = 256 are + /// excluded — they must not leak a club's board). Backs the flat + /// GET /announcements/mine list, which is a different wire shape + /// from the per-club unread rollup. + public async Task> AnnouncementsForMemberClubsAsync( + long playerId, int take = 100) + { + var clubIds = await db.ClubMemberships + .Where(m => m.PlayerId == playerId && (m.Permissions & 384) == 0) + .Select(m => m.ClubId) + .ToListAsync(); + return await AnnouncementsAcrossClubsAsync(clubIds, take); + } + + /// Every announcement across the clubs the player subscribes + /// to. Backs the flat GET /announcements/subscription/mine + /// list. + public async Task> AnnouncementsForSubscribedClubsAsync( + long playerId, int take = 100) + { + var clubIds = await db.ClubSubscriptions + .Where(s => s.PlayerId == playerId) + .Select(s => s.ClubId) + .ToListAsync(); + return await AnnouncementsAcrossClubsAsync(clubIds, take); + } + + private async Task> AnnouncementsAcrossClubsAsync( + List clubIds, int take) + { + if (clubIds.Count == 0) return new List(); + // Disbanded clubs keep their rows but must not surface on the + // aggregated feed, so the club set is re-filtered on State. + var live = await db.Clubs + .Where(c => clubIds.Contains(c.Id) && c.State == 0) + .Select(c => c.Id) + .ToListAsync(); + if (live.Count == 0) return new List(); + return await db.ClubAnnouncements + .Where(a => live.Contains(a.ClubId)) + .OrderByDescending(a => a.CreatedAt) + .ThenByDescending(a => a.Id) + .Take(Math.Clamp(take, 1, 500)) + .ToListAsync(); + } + /// /// Upserts a read-marker so subsequent /// / calls skip the @@ -522,6 +650,50 @@ public class ClubService(DorkNetDbContext db) public Task AnnouncementAsync(long clubId, long announcementId) => db.ClubAnnouncements.FirstOrDefaultAsync(a => a.Id == announcementId && a.ClubId == clubId); + /// Create a club announcement. Returns the new row's id, or null + /// when the caller may not manage the club. + public async Task CreateAnnouncementAsync( + long clubId, long callerId, string title, string body, string imageName) + { + if (!await CanManageAsync(clubId, callerId)) return null; + var row = new ClubAnnouncementEntity + { + ClubId = clubId, + AuthorPlayerId = callerId, + Title = Truncate(title, 200), + Body = Truncate(body, 4000), + ImageName = Truncate(imageName, 256), + }; + db.ClubAnnouncements.Add(row); + await db.SaveChangesAsync(); + return row.Id; + } + + /// Edit an existing announcement in place. Null arguments leave + /// the corresponding field untouched. Returns false when the caller may + /// not manage the club or the row is unknown. + public async Task UpdateAnnouncementAsync( + long clubId, long announcementId, long callerId, + string? title, string? body, string? imageName) + { + if (!await CanManageAsync(clubId, callerId)) return false; + var row = await db.ClubAnnouncements + .FirstOrDefaultAsync(a => a.Id == announcementId && a.ClubId == clubId); + if (row is null) return false; + if (title is not null) row.Title = Truncate(title, 200); + if (body is not null) row.Body = Truncate(body, 4000); + if (imageName is not null) row.ImageName = Truncate(imageName, 256); + row.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return true; + } + + private static string Truncate(string? value, int max) + { + value = (value ?? string.Empty).Trim(); + return value.Length <= max ? value : value[..max]; + } + /// Delete an announcement after verifying the caller can /// manage the club. Idempotent: missing row → no-op. public async Task DeleteAnnouncementAsync(long clubId, long announcementId, long callerId) diff --git a/DorkNet.Server/Services/GitHubIssueService.cs b/DorkNet.Server/Services/GitHubIssueService.cs new file mode 100644 index 0000000..e09ef93 --- /dev/null +++ b/DorkNet.Server/Services/GitHubIssueService.cs @@ -0,0 +1,182 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace DorkNet.Server.Services; + +/// One GitHub issue, reduced to what QA linking needs. +public sealed record GitHubIssue(int Number, string Url, string State, string Title); + +/// +/// Files and reads GitHub issues for the QA test-case tooling. +/// +/// Deliberately narrow: create, read state, comment. Everything else a QA +/// workflow might want (labels beyond the fixed set, milestones, assignees) is +/// out of scope until something asks for it. +/// +public interface IGitHubIssues +{ + /// False when no token/repo is configured. Callers must check this + /// and degrade rather than throw — a server without GitHub credentials is a + /// normal deployment, not a broken one. + bool IsConfigured { get; } + + /// owner/name, or empty when unconfigured. + string Repository { get; } + + Task CreateAsync(string title, string body, IEnumerable labels, CancellationToken ct = default); + + Task GetAsync(int number, CancellationToken ct = default); + + Task CommentAsync(int number, string body, CancellationToken ct = default); +} + +/// +/// REST implementation over api.github.com. +/// +/// Configured with GitHub:Token (a PAT with issues:write on the +/// target repo) and GitHub:Repository (owner/name). Both are +/// read once at construction; absent either, is +/// false and every call returns null rather than throwing, so QA endpoints +/// answer "not configured" instead of 500ing. +/// +public sealed class GitHubIssueService : IGitHubIssues +{ + private const string ApiRoot = "https://api.github.com"; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _log; + private readonly string _token; + + public GitHubIssueService( + IConfiguration config, + IHttpClientFactory httpClientFactory, + ILogger log) + { + _httpClientFactory = httpClientFactory; + _log = log; + _token = config["GitHub:Token"] ?? string.Empty; + Repository = (config["GitHub:Repository"] ?? string.Empty).Trim().Trim('/'); + + if (!IsConfigured) + { + _log.LogInformation( + "[github] no token/repository configured; issue linking is disabled"); + } + else + { + _log.LogInformation("[github] issue linking enabled for {Repository}", Repository); + } + } + + public bool IsConfigured => _token.Length > 0 && Repository.Contains('/'); + + public string Repository { get; } + + public async Task CreateAsync( + string title, string body, IEnumerable labels, CancellationToken ct = default) + { + if (!IsConfigured) return null; + + var payload = JsonSerializer.Serialize(new + { + title, + body, + labels = labels.Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().ToArray(), + }); + + return await SendAsync(HttpMethod.Post, $"/repos/{Repository}/issues", payload, ct); + } + + public Task GetAsync(int number, CancellationToken ct = default) => + IsConfigured + ? SendAsync(HttpMethod.Get, $"/repos/{Repository}/issues/{number}", null, ct) + : Task.FromResult(null); + + public async Task CommentAsync(int number, string body, CancellationToken ct = default) + { + if (!IsConfigured) return false; + + var payload = JsonSerializer.Serialize(new { body }); + using var response = await SendRawAsync( + HttpMethod.Post, $"/repos/{Repository}/issues/{number}/comments", payload, ct); + return response is { IsSuccessStatusCode: true }; + } + + private async Task SendAsync( + HttpMethod method, string path, string? payload, CancellationToken ct) + { + using var response = await SendRawAsync(method, path, payload, ct); + if (response is null || !response.IsSuccessStatusCode) return null; + + try + { + var json = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + return new GitHubIssue( + root.GetProperty("number").GetInt32(), + root.GetProperty("html_url").GetString() ?? string.Empty, + root.GetProperty("state").GetString() ?? "open", + root.TryGetProperty("title", out var t) ? t.GetString() ?? string.Empty : string.Empty); + } + catch (Exception ex) + { + _log.LogWarning(ex, "[github] could not read the issue response from {Method} {Path}", method, path); + return null; + } + } + + private async Task SendRawAsync( + HttpMethod method, string path, string? payload, CancellationToken ct) + { + try + { + var client = _httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(method, ApiRoot + path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + // GitHub rejects requests without one. + request.Headers.UserAgent.ParseAdd("DorkNet-QA"); + request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); + if (payload is not null) + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + + var response = await client.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + { + // A 404 on a GET is ordinary (deleted issue); anything else is + // worth seeing, but never fatal to the caller. + var level = method == HttpMethod.Get && response.StatusCode == HttpStatusCode.NotFound + ? LogLevel.Debug + : LogLevel.Warning; + _log.Log(level, "[github] {Method} {Path} -> {Status}", method, path, (int)response.StatusCode); + } + return response; + } + catch (Exception ex) + { + _log.LogWarning(ex, "[github] {Method} {Path} failed", method, path); + return null; + } + } + + /// Pull the issue number out of a stored issue URL. + /// + /// The link lives in TestCaseEntity.JiraBugUrl — the field Rec + /// Room's own QA tooling used for the same purpose — so the only thing + /// identifying it as a GitHub issue is the URL shape. Returns null for + /// anything that isn't one, which is how a genuine Jira link left over + /// from the original data is ignored rather than misparsed. + public static int? IssueNumberFromUrl(string? url) + { + if (string.IsNullOrWhiteSpace(url)) return null; + var match = Regex.Match(url, @"github\.com/[^/]+/[^/]+/issues/(?\d+)", + RegexOptions.IgnoreCase); + return match.Success && int.TryParse(match.Groups["number"].Value, out var number) + ? number + : null; + } +} diff --git a/DorkNet.Server/Services/NotificationService.cs b/DorkNet.Server/Services/NotificationService.cs index 56a8799..02900f8 100644 --- a/DorkNet.Server/Services/NotificationService.cs +++ b/DorkNet.Server/Services/NotificationService.cs @@ -86,31 +86,41 @@ public class NotificationService( /// concrete example: GiftPackageReceived registers /// "30" and GiftPackageReceivedImmediate /// registers "31"; sending the enum name misses. - /// + /// + /// + /// The default is numeric, and that matters. Both enum + /// overloads of the client's RegisterHandler + /// (OMHPBIEHOHN.PNDNMHHDOOJ) call Int32.ToString() on the id + /// and register ONLY that numeric string — + /// RecNet.Runtime/OMHPBIEHOHN.txt:662 and :733. There is no + /// enum-name companion key. The dispatcher does a plain case-sensitive + /// Dictionary.TryGetValue on the envelope's Id and returns + /// silently on a miss (OMHPBIEHOHN.txt:4052-4237), so a wrong key is + /// not an error anywhere — the push simply never arrives. + /// + /// This used to default to id.ToString() (the enum NAME), which + /// silently dropped every enum-registered push that wasn't explicitly listed: + /// friend-request add/accept/remove and the RelationshipsInvalid refresh, + /// forced logout, maintenance broadcasts, live token-balance updates after a + /// level-up or purchase, consumable inventory changes, and invention + /// moderation state. Only the six ids below are genuinely string-keyed; + /// everything else must go out as a number. Verify against the binary before + /// adding an entry here — guessing wrong fails silently in both directions. + /// private static string WireName(PushNotificationId id) => id switch { - // Custom short names — registered under a bespoke string, - // no integer companion in the dispatch dict. + // The ONLY ids registered under a bespoke string. Everything else is + // numeric — see the note below for why the default flipped. + PushNotificationId.SubscriptionUpdateProfile => "AccountUpdate", PushNotificationId.SubscriptionUpdatePresence => "PresenceUpdate", PushNotificationId.SubscriptionUpdateGameSession => "RoomInstanceUpdate", - PushNotificationId.SubscriptionUpdateProfile => "AccountUpdate", - // Int-only registrations — sending the enum name misses. - PushNotificationId.MessageReceived => "2", - PushNotificationId.MessageDeleted => "3", - PushNotificationId.ModerationKick => "22", - PushNotificationId.ModerationKickAttemptFailed => "23", - PushNotificationId.ModerationRoomBan => "24", - PushNotificationId.GiftPackageReceived => "30", - PushNotificationId.GiftPackageReceivedImmediate => "31", - PushNotificationId.PlayerEventCreated => "80", - PushNotificationId.PlayerEventUpdated => "81", - PushNotificationId.PlayerEventDeleted => "82", - PushNotificationId.PlayerEventResponseChanged => "83", - PushNotificationId.PlayerEventResponseDeleted => "84", - PushNotificationId.PlayerEventStateChanged => "85", - // Everything else: enum.ToString() matches because the watch's - // enum-overload RegisterHandler registers both forms. - _ => id.ToString(), + PushNotificationId.SubscriptionUpdateRoom => "RoomUpdate", + PushNotificationId.ChatMessageReceived => "ChatMessageReceived", + PushNotificationId.CommunityBoardUpdate => "CommunityBoardUpdate", + + // Numeric, because the client registered these through the enum + // overload. This is the default for a reason — see below. + _ => ((int)id).ToString(), }; private static string Serialize(PushNotificationId id, object? msg) => diff --git a/DorkNet.Server/Services/OnlinePresenceService.cs b/DorkNet.Server/Services/OnlinePresenceService.cs index b38c92f..a783fee 100644 --- a/DorkNet.Server/Services/OnlinePresenceService.cs +++ b/DorkNet.Server/Services/OnlinePresenceService.cs @@ -45,7 +45,11 @@ public class OnlinePresenceService public OnlinePresenceService(IConnectionMultiplexer? redis = null) => _redis = redis; - public async Task AddConnectionAsync(long playerId, string connectionId) + /// Register one SignalR connection. Returns true when that was the + /// player's FIRST active connection, i.e. they just came online and friends + /// should be told. Mirrors , which + /// reports the opposite transition. + public async Task AddConnectionAsync(long playerId, string connectionId) { if (_redis is { } mux) { @@ -59,13 +63,21 @@ public class OnlinePresenceService if (added) { var size = await db.SetLengthAsync(connsKey); - if (size == 1) await db.SetAddAsync(OnlineSetKey, playerId); + if (size == 1) + { + await db.SetAddAsync(OnlineSetKey, playerId); + return true; + } } - return; + return false; } var set = _localConns.GetOrAdd(playerId, _ => new HashSet()); - lock (set) set.Add(connectionId); + lock (set) + { + set.Add(connectionId); + return set.Count == 1; + } } /// Remove one SignalR connection. Returns true when that was diff --git a/DorkNet.Server/Services/PlayerAccountPurge.cs b/DorkNet.Server/Services/PlayerAccountPurge.cs new file mode 100644 index 0000000..ec9ae2f --- /dev/null +++ b/DorkNet.Server/Services/PlayerAccountPurge.cs @@ -0,0 +1,153 @@ +using Microsoft.EntityFrameworkCore; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Services; + +/// +/// The single account-erasure cascade used by every "remove this player" +/// path in the server: the admin SPA's +/// DELETE api/admin/v1/players/{id} and the 2023 client's +/// DELETE account/me ("delete local account"). +/// +/// Personal rows are deleted outright; authored durable content (rooms, +/// clubs, inventions, playlists, chat threads …) is reassigned to the +/// system account so other players' worlds don't lose their contents when +/// one author leaves. Kept as a static helper — not a DI service — so both +/// call sites share one implementation without either controller having to +/// grow a constructor dependency. +/// +/// Callers are responsible for the surrounding transaction, for removing +/// the Players row itself, and for kicking the live session. +/// +public static class PlayerAccountPurge +{ + /// Delete/reassign every row that references + /// . Returns a one-line audit summary. + public static async Task PurgeAsync( + DorkNetDbContext db, long playerId, long systemPlayerId) + { + var now = DateTime.UtcNow; + var deleted = 0; + var reassigned = 0; + + deleted += await db.Avatars.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.PlayerSettings.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.Relationships.Where(x => x.RequesterId == playerId || x.TargetId == playerId).ExecuteDeleteAsync(); + deleted += await db.CurrencyBalances.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.PlayerInventory.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.PlayerDevices.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.NotificationPrefs.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.CohortAssignments.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.PushTokens.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.PlatformIgnores.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.Cards.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.ObjectiveProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.GameRewardSelections.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.CouponRedemptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.LeaderboardStats.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.PlayerElo.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.RoyalePlayerProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.RoyaleMatchPlayers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + + deleted += await db.Messages.Where(x => x.SenderPlayerId == playerId || x.RecipientPlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.ChatMessages.Where(x => x.SenderPlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.ChatThreadMembers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + reassigned += await db.ChatThreads + .Where(x => x.CreatorPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId)); + + deleted += await db.Cheers.Where(x => x.FromPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.Reports.Where(x => x.ReporterPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.BugReports.Where(x => x.ReporterPlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.Subscriptions.Where(x => x.SubscriberPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync(); + + deleted += await db.ClubMemberships.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.ClubSubscriptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.ClubAnnouncementReads.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + reassigned += await db.Clubs + .Where(x => x.CreatorPlayerId == playerId) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.CreatorPlayerId, systemPlayerId) + .SetProperty(x => x.UpdatedAt, now)); + reassigned += await db.ClubAnnouncements + .Where(x => x.AuthorPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.AuthorPlayerId, systemPlayerId)); + + deleted += await db.RoomBookmarks.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.RoomVisits.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.RoomRoles.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + deleted += await db.RoomBans.Where(x => x.BannedPlayerId == playerId).ExecuteDeleteAsync(); + reassigned += await db.RoomBans + .Where(x => x.BannedByPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.BannedByPlayerId, systemPlayerId)); + reassigned += await db.RoomRoles + .Where(x => x.GrantedByPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.GrantedByPlayerId, systemPlayerId)); + reassigned += await db.Rooms + .Where(x => x.CreatorPlayerId == playerId) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.CreatorPlayerId, systemPlayerId) + .SetProperty(x => x.UpdatedAt, now)); + reassigned += await db.RoomDataBlobs + .Where(x => x.UploadedByPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.UploadedByPlayerId, systemPlayerId)); + reassigned += await db.RoomKeys + .Where(x => x.CreatorPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId)); + deleted += await db.RoomKeyPurchases.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + + deleted += await db.PlaylistInteractions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + reassigned += await db.Playlists + .Where(x => x.CreatorPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId)); + + deleted += await db.PlayerEventResponses + .Where(x => x.PlayerId == playerId + || db.PlayerEvents.Where(e => e.CreatorPlayerId == playerId).Select(e => e.Id).Contains(x.EventId)) + .ExecuteDeleteAsync(); + deleted += await db.PlayerEvents.Where(x => x.CreatorPlayerId == playerId).ExecuteDeleteAsync(); + + deleted += await db.PrivateInstanceInvitees.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync(); + var privateInstanceIds = await db.PrivateInstances + .Where(x => x.OwnerPlayerId == playerId) + .Select(x => x.Id) + .ToListAsync(); + if (privateInstanceIds.Count > 0) + { + deleted += await db.PrivateInstanceInvitees + .Where(x => privateInstanceIds.Contains(x.PrivateInstanceId)) + .ExecuteDeleteAsync(); + deleted += await db.PrivateInstances + .Where(x => privateInstanceIds.Contains(x.Id)) + .ExecuteDeleteAsync(); + } + + deleted += await db.GiftPackages + .Where(x => x.RecipientPlayerId == playerId) + .ExecuteDeleteAsync(); + if (playerId <= int.MaxValue) + { + reassigned += await db.GiftPackages + .Where(x => x.FromPlayerId == (int)playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.FromPlayerId, (int?)null)); + } + + reassigned += await db.Inventions + .Where(x => x.CreatorPlayerId == playerId) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.CreatorPlayerId, systemPlayerId) + .SetProperty(x => x.UpdatedAt, now)); + reassigned += await db.Photos + .Where(x => x.UploaderPlayerId == playerId) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.UploaderPlayerId, systemPlayerId) + .SetProperty(x => x.DeletedAt, now) + .SetProperty(x => x.IsPublic, false)); + + reassigned += await db.SignupCodes + .Where(x => x.RedeemedByPlayerId == playerId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null)); + + return $"deleted_rows={deleted} reassigned_rows={reassigned}"; + } +} diff --git a/DorkNet.Server/Services/TestCaseIssueReconciler.cs b/DorkNet.Server/Services/TestCaseIssueReconciler.cs new file mode 100644 index 0000000..50002e7 --- /dev/null +++ b/DorkNet.Server/Services/TestCaseIssueReconciler.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.Options; +using DorkNet.Server.Controllers.API.TestCaseManagement; +using DorkNet.Server.Data; + +namespace DorkNet.Server.Services; + +/// +/// Keeps linked test cases in step with their GitHub issues without anyone +/// having to press anything. +/// +/// A closed issue means the bug is fixed, so its test case is due for +/// another run; a reopened one means it came back. Waiting for a human to +/// notice that defeats the point of linking them, so this sweeps on an +/// interval. The reconcile logic itself lives in +/// and is shared with the +/// manual endpoint, so the automatic and manual paths cannot drift. +/// +/// Does nothing at all when GitHub is unconfigured — a server without a +/// token is a normal deployment, not a broken one, and it should not log a +/// failure every interval forever. +/// +public sealed class TestCaseIssueReconciler( + IServiceScopeFactory scopeFactory, + IGitHubIssues github, + IConfiguration config, + ILogger log) : BackgroundService +{ + /// How often to sweep. Issue state changes are not urgent — a QA + /// pass is a human-scale activity — and each sweep costs one API call per + /// linked case against a rate limit shared with everything else using the + /// token, so this is deliberately slow. Override with + /// GitHub:ReconcileIntervalMinutes; 0 or less disables the sweep + /// while leaving the manual endpoint working. + private const int DefaultIntervalMinutes = 15; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!github.IsConfigured) + { + log.LogInformation( + "[testcase-issue] GitHub not configured; the reconciler is idle"); + return; + } + + var minutes = config.GetValue("GitHub:ReconcileIntervalMinutes") + ?? DefaultIntervalMinutes; + if (minutes <= 0) + { + log.LogInformation( + "[testcase-issue] reconcile interval is {Minutes}; sweeps disabled", minutes); + return; + } + + log.LogInformation( + "[testcase-issue] reconciling against {Repo} every {Minutes} minute(s)", + github.Repository, minutes); + + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(minutes)); + while (await SafeWaitAsync(timer, stoppingToken)) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var changes = await TestCaseIssuesController.ReconcileAsync( + db, github, log, stoppingToken); + if (changes.Count > 0) + log.LogInformation("[testcase-issue] sweep updated {Count} case(s)", changes.Count); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + // One bad sweep must not take the loop down — GitHub being + // unreachable for a minute is not a reason to stop + // reconciling for the lifetime of the process. + log.LogWarning(ex, "[testcase-issue] reconcile sweep failed; retrying next interval"); + } + } + } + + private static async Task SafeWaitAsync(PeriodicTimer timer, CancellationToken ct) + { + try { return await timer.WaitForNextTickAsync(ct); } + catch (OperationCanceledException) { return false; } + } +} diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index ddbcc53..f2c3f41 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -113,6 +113,7 @@ public static class DatabaseBootstrap await EnsureGiftPackageColumnsAsync(db); await EnsureAvatarColumnsAsync(db); await EnsureMarch2023TablesAsync(db); + await EnsureClient2023WireColumnsAsync(db); // Coach system account at Player.Id=1. The RR-Original room seeder // below assigns CreatorPlayerId=1 to every canonical room @@ -817,6 +818,134 @@ public static class DatabaseBootstrap await db.Database.ExecuteSqlRawAsync(sql); } + + /// Schema introduced by the 2023-client wire-contract work: + /// playlist settings the client can actually set, a host-chosen room code, + /// per-role club permissions, the club image gallery, per-message chat + /// moderation state, and QA comments on test cases. + /// + /// This exists for the same reason as the other Ensure* helpers, and the + /// reason is worth stating plainly because forgetting it is a live outage: + /// the Postgres path is EnsureCreated-only and never replays migrations, so + /// an EF migration alone adds nothing to an existing database. A handler + /// that selects one of these columns then fails with "column does not + /// exist" on every request — which is exactly how club home, matchmake/dorm + /// and the presence heartbeat all started returning 500 at once. + /// + /// Every statement is idempotent, so this is safe to run on a fresh + /// database (where EnsureCreated already built everything) and on one that + /// predates the change. + private static async Task EnsureClient2023WireColumnsAsync(DorkNetDbContext db) + { + var provider = db.Database.ProviderName ?? string.Empty; + if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase)) + { + var statements = new[] + { + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""Accessibility"" integer NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""SupportsLevelVoting"" boolean NOT NULL DEFAULT false;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""SupportsJuniors"" boolean NOT NULL DEFAULT true;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""SupportsScreens"" boolean NOT NULL DEFAULT true;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""SupportsTeleportVR"" boolean NOT NULL DEFAULT true;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""SupportsWalkVR"" boolean NOT NULL DEFAULT true;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""WarningMask"" integer NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Playlists"" ADD COLUMN IF NOT EXISTS ""CustomWarning"" character varying(512) NOT NULL DEFAULT '';", + @"ALTER TABLE ""PrivateInstances"" ADD COLUMN IF NOT EXISTS ""RoomCode"" character varying(16) NOT NULL DEFAULT '';", + @"ALTER TABLE ""Clubs"" ADD COLUMN IF NOT EXISTS ""MinLevel"" integer NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Clubs"" ADD COLUMN IF NOT EXISTS ""ClubChatEnabled"" boolean NOT NULL DEFAULT true;", + @"ALTER TABLE ""ChatMessages"" ADD COLUMN IF NOT EXISTS ""ModerationState"" integer NOT NULL DEFAULT 0;", + @"ALTER TABLE ""TestCases"" ADD COLUMN IF NOT EXISTS ""CommentsJson"" text NOT NULL DEFAULT '[]';", + """ + CREATE TABLE IF NOT EXISTS "ClubRolePermissions" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "ClubId" bigint NOT NULL, + "MembershipType" integer NOT NULL, + "EditDetails" boolean NOT NULL DEFAULT false, + "ApproveMember" boolean NOT NULL DEFAULT false, + "CreateEvent" boolean NOT NULL DEFAULT false, + "PostAnnouncement" boolean NOT NULL DEFAULT false, + "EditPermissionSettings" boolean NOT NULL DEFAULT false, + "BanUnban" boolean NOT NULL DEFAULT false, + "UpdatedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubRolePermissions_ClubId_MembershipType"" ON ""ClubRolePermissions"" (""ClubId"", ""MembershipType"");", + """ + CREATE TABLE IF NOT EXISTS "ClubAdditionalImages" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "ClubId" bigint NOT NULL, + "Slot" integer NOT NULL, + "ImageName" character varying(256) NOT NULL DEFAULT '', + "UpdatedAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");", + }; + foreach (var sql in statements) + await db.Database.ExecuteSqlRawAsync(sql); + return; + } + + // SQLite gets these from Database.Migrate(), but a database that was + // baselined past the migration (BaselineExistingSchemaIfNeeded) would + // skip them, so add the same columns defensively. SQLite has no + // ADD COLUMN IF NOT EXISTS; the duplicate-column error is the success + // case on a database that already has them. + var sqlite = new[] + { + @"ALTER TABLE ""Playlists"" ADD COLUMN ""Accessibility"" INTEGER NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""SupportsLevelVoting"" INTEGER NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""SupportsJuniors"" INTEGER NOT NULL DEFAULT 1;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""SupportsScreens"" INTEGER NOT NULL DEFAULT 1;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""SupportsTeleportVR"" INTEGER NOT NULL DEFAULT 1;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""SupportsWalkVR"" INTEGER NOT NULL DEFAULT 1;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""WarningMask"" INTEGER NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Playlists"" ADD COLUMN ""CustomWarning"" TEXT NOT NULL DEFAULT '';", + @"ALTER TABLE ""PrivateInstances"" ADD COLUMN ""RoomCode"" TEXT NOT NULL DEFAULT '';", + @"ALTER TABLE ""Clubs"" ADD COLUMN ""MinLevel"" INTEGER NOT NULL DEFAULT 0;", + @"ALTER TABLE ""Clubs"" ADD COLUMN ""ClubChatEnabled"" INTEGER NOT NULL DEFAULT 1;", + @"ALTER TABLE ""ChatMessages"" ADD COLUMN ""ModerationState"" INTEGER NOT NULL DEFAULT 0;", + @"ALTER TABLE ""TestCases"" ADD COLUMN ""CommentsJson"" TEXT NOT NULL DEFAULT '[]';", + }; + foreach (var sql in sqlite) + { + try { await db.Database.ExecuteSqlRawAsync(sql); } catch { } + } + + var sqliteTables = new[] + { + """ + CREATE TABLE IF NOT EXISTS "ClubRolePermissions" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_ClubRolePermissions" PRIMARY KEY AUTOINCREMENT, + "ClubId" INTEGER NOT NULL, + "MembershipType" INTEGER NOT NULL, + "EditDetails" INTEGER NOT NULL DEFAULT 0, + "ApproveMember" INTEGER NOT NULL DEFAULT 0, + "CreateEvent" INTEGER NOT NULL DEFAULT 0, + "PostAnnouncement" INTEGER NOT NULL DEFAULT 0, + "EditPermissionSettings" INTEGER NOT NULL DEFAULT 0, + "BanUnban" INTEGER NOT NULL DEFAULT 0, + "UpdatedAt" TEXT NOT NULL DEFAULT '' + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubRolePermissions_ClubId_MembershipType"" ON ""ClubRolePermissions"" (""ClubId"", ""MembershipType"");", + """ + CREATE TABLE IF NOT EXISTS "ClubAdditionalImages" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_ClubAdditionalImages" PRIMARY KEY AUTOINCREMENT, + "ClubId" INTEGER NOT NULL, + "Slot" INTEGER NOT NULL, + "ImageName" TEXT NOT NULL DEFAULT '', + "UpdatedAt" TEXT NOT NULL DEFAULT '' + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_ClubAdditionalImages_ClubId_Slot"" ON ""ClubAdditionalImages"" (""ClubId"", ""Slot"");", + }; + foreach (var sql in sqliteTables) + { + try { await db.Database.ExecuteSqlRawAsync(sql); } catch { } + } + } + private static async Task EnsureMarch2023TablesAsync(DorkNetDbContext db) { var provider = db.Database.ProviderName ?? string.Empty; diff --git a/DorkNet.Server/Startup/RequestTracingExtensions.cs b/DorkNet.Server/Startup/RequestTracingExtensions.cs index a7cd320..d3653b7 100644 --- a/DorkNet.Server/Startup/RequestTracingExtensions.cs +++ b/DorkNet.Server/Startup/RequestTracingExtensions.cs @@ -127,7 +127,13 @@ public static class RequestTracingExtensions var safeResponse = RedactSecrets(responseBody) ?? ""; var trimmedRequest = safeRequest is null ? null : (safeRequest.Length > 200 ? safeRequest[..200] + "..." : safeRequest); - var trimmedResponse = safeResponse.Length > 300 ? safeResponse[..300] + "..." : safeResponse; + // 300 chars is plenty to identify an error, but a room-details + // payload is a few KB and the whole point of capturing it is to + // see which key is missing — a truncated one answers nothing. + var responseCap = IsSuccessBodyPath(path) ? 8000 : 300; + var trimmedResponse = safeResponse.Length > responseCap + ? safeResponse[..responseCap] + "..." + : safeResponse; var authPresence = DescribeAuthPresence(ctx.Request); var level = thrown is not null || status >= 500 ? LogLevel.Error : status >= 400 ? LogLevel.Warning @@ -241,12 +247,38 @@ public static class RequestTracingExtensions || ext.Equals(".log", StringComparison.OrdinalIgnoreCase); } + /// Paths whose SUCCESS bodies are logged as well as their failures. + /// + /// Worth stating why this list has to exist: when the client rejects a + /// response it answers 200 and the client throws while reading it, so the + /// server log shows a clean 200 and the client shows a bare "Failed to …" + /// with no URL. Neither side records the payload, and without it every + /// diagnosis is guesswork about which key the strict reader tripped on. + /// + /// Override with Trace__SuccessBodyPaths (comma-separated substrings, + /// empty to disable). Bodies are still redacted and capped at 300 chars by + /// the caller. + private static readonly string[] SuccessBodyPaths = ResolveSuccessBodyPaths(); + + private static string[] ResolveSuccessBodyPaths() + { + var configured = Environment.GetEnvironmentVariable("Trace__SuccessBodyPaths"); + if (configured is null) + return ["/player", "/thread", "/clone", "/rooms/", "/matchmake/"]; + + return configured + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToArray(); + } + + private static bool IsSuccessBodyPath(string path) => + SuccessBodyPaths.Any(p => path.Contains(p, StringComparison.OrdinalIgnoreCase)); + private static bool ShouldReadResponseBody(string? contentType, int status, string path) { - // TEMP diagnostic: always read the /player roster body (even 200s) so - // [presence-debug] can dump the exact online payload the watch renders. - var isPresence = path.Equals("/player", StringComparison.OrdinalIgnoreCase); - if (status < 400 && !isPresence) return false; + // Failures are always read; successes only for the paths above, because + // a rejected-but-200 response is invisible otherwise. + if (status < 400 && !IsSuccessBodyPath(path)) return false; if (string.IsNullOrWhiteSpace(contentType)) return true; return contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) || contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase); diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index 793d230..04e66ca 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -210,6 +210,13 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddHttpClient(); services.AddSingleton(); + // QA test cases ↔ GitHub issues. Singleton because it holds only + // configuration; the reconciler makes its own scope for the DbContext. + // Both are registered unconditionally — the service reports + // IsConfigured=false without a token and the reconciler then idles, + // so a deployment with no GitHub credentials behaves normally. + services.AddSingleton(); + services.AddHostedService(); // Scoped (per-request) — these services hold DbContext references and // must share the request's scope to participate in the request's // transaction lifecycle. Pre-PR-3 they were Singleton with in-process diff --git a/DorkNet.Server/admin-ui/src/pages/Activity.tsx b/DorkNet.Server/admin-ui/src/pages/Activity.tsx index 9063dc9..d67aff8 100644 --- a/DorkNet.Server/admin-ui/src/pages/Activity.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Activity.tsx @@ -1,25 +1,30 @@ import { Audit } from './Audit'; import { PlayerLogs } from './PlayerLogs'; +import { TestCases } from './TestCases'; import { PageHeader } from '../components/PageHeader'; import { Tabs, useTabParam } from '../components/Tabs'; -// Both log views in one place: the admin audit trail and the per-player -// HTTP request ring buffer. Old /audit and /logs routes redirect here. +// Internal operations views in one place: the admin audit trail, the +// per-player HTTP request ring buffer, and QA test cases with the GitHub +// issues filed against them. Old /audit and /logs routes redirect here. export function Activity() { - const [tab, setTab] = useTabParam<'audit' | 'logs'>('audit', ['audit', 'logs']); + const [tab, setTab] = useTabParam<'audit' | 'logs' | 'testcases'>( + 'audit', ['audit', 'logs', 'testcases']); return (
- + {tab === 'audit' && } {tab === 'logs' && } + {tab === 'testcases' && }
); } diff --git a/DorkNet.Server/admin-ui/src/pages/Room.tsx b/DorkNet.Server/admin-ui/src/pages/Room.tsx index 8c3f216..936030a 100644 --- a/DorkNet.Server/admin-ui/src/pages/Room.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Room.tsx @@ -17,7 +17,7 @@ import { ArrowLeft, RefreshCw, Trash } from '../components/Icons'; // modals: general info edit, owner / co-owner / mod / host // management, live photon instances, recent visitors, danger zone. -type Tab = 'general' | 'roles' | 'instances' | 'leaderboards' | 'visitors' | 'danger'; +type Tab = 'general' | 'roles' | 'subrooms' | 'instances' | 'leaderboards' | 'visitors' | 'danger'; const ROLE_LABEL: Record<0 | 1 | 2, string> = { 0: 'Co-owner', @@ -67,7 +67,7 @@ export function Room() { <>
- {(['general', 'roles', 'instances', 'leaderboards', 'visitors', 'danger'] as Tab[]).map(t => ( + {(['general', 'roles', 'subrooms', 'instances', 'leaderboards', 'visitors', 'danger'] as Tab[]).map(t => ( +
+ {error &&
{error}
} + {data && data.length === 0 && } + {data && data.length > 0 && ( +
    + {data.map(sub => { + const pending = edits[sub.subRoomId]; + const dirty = pending !== undefined && Number(pending) !== sub.maxPlayers; + return ( +
  • +
    +
    + {sub.name || '(unnamed)'} + SubRoom {sub.subRoomId} + {sub.isSandbox && Sandbox} + {!sub.canMatchmakeInto && No matchmaking} +
    + {sub.dataBlobName && ( +
    {sub.dataBlobName}
    + )} +
    +
    + + setEdits(prev => ({ ...prev, [sub.subRoomId]: e.target.value }))} + className="input w-20 text-sm" + /> + +
    +
  • + ); + })} +
+ )} + + ); +} + function InstancesTab({ roomId }: { roomId: number }) { const toast = useToast(); const { data, loading, error, refresh } = useApi(`/rooms/${roomId}/instances`); diff --git a/DorkNet.Server/admin-ui/src/pages/TestCases.tsx b/DorkNet.Server/admin-ui/src/pages/TestCases.tsx new file mode 100644 index 0000000..7eec33f --- /dev/null +++ b/DorkNet.Server/admin-ui/src/pages/TestCases.tsx @@ -0,0 +1,193 @@ +import { useState } from 'react'; +import { api } from '../lib/api'; +import { useApi } from '../lib/useApi'; +import { PageHeader } from '../components/PageHeader'; +import { Empty } from '../components/Empty'; +import { useToast } from '../components/Toast'; +import { RefreshCw, Trash } from '../components/Icons'; + +// QA test cases and the GitHub issues filed against them. +// +// Status values match the client's TestCaseStatus enum. Only Failed<->Passed +// are the reconciler's to move (a closed issue means fixed), so a Claimed case +// is never rewritten by a sync — see docs/admin.md. +const STATUS: Record = { + 0: { label: 'Not yet tested', className: 'badge-neutral' }, + 1: { label: 'Claimed', className: 'badge-neutral' }, + 2: { label: 'Failed', className: 'badge-banned' }, + 3: { label: 'Passed', className: 'badge-online' }, +}; + +interface TestCaseRow { + id: string; + key: string; + title: string; + roomName: string; + status: number; + testPassId: number | null; + issueUrl: string; + issueNumber: number | null; + updatedAt: string; +} + +interface TestCaseList { + githubConfigured: boolean; + repository: string; + cases: TestCaseRow[]; +} + +export function TestCases() { + const toast = useToast(); + const [status, setStatus] = useState(''); + const query = status === '' ? '' : `?status=${status}`; + const { data, loading, error, refresh } = useApi(`/testcases${query}`); + const [busy, setBusy] = useState(null); + const [syncing, setSyncing] = useState(false); + + const fileIssue = async (row: TestCaseRow) => { + setBusy(row.id); + try { + const res = await api<{ issue: { number: number; url: string }; created: boolean }>( + `/testcases/${row.id}/issue`, { method: 'POST' }, + ); + toast.push( + res.created + ? `Filed #${res.issue.number} for ${row.key}` + : `${row.key} is already linked to #${res.issue.number}`, + 'success', + ); + refresh(); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setBusy(null); + } + }; + + const unlink = async (row: TestCaseRow) => { + setBusy(row.id); + try { + // Unlink only — the issue itself is deliberately left open. + await api(`/testcases/${row.id}/issue`, { method: 'DELETE' }); + toast.push(`Unlinked ${row.key}`, 'success'); + refresh(); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setBusy(null); + } + }; + + const sync = async () => { + setSyncing(true); + try { + const res = await api<{ reconciled: number }>('/testcases/issues/sync', { method: 'POST' }); + toast.push( + res.reconciled === 0 ? 'Everything already in sync' : `Updated ${res.reconciled} case(s)`, + 'success', + ); + refresh(); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setSyncing(false); + } + }; + + return ( +
+ + + + + } + /> + + {/* A server with no GitHub token is an ordinary deployment, so this is a + note rather than an error. */} + {data && !data.githubConfigured && ( +
+ GitHub issue linking is off. Set GitHub:Token and{' '} + GitHub:Repository to enable it. +
+ )} + {error &&
{error}
} + +
+ {data && data.cases.length === 0 && } + {data && data.cases.length > 0 && ( +
    + {data.cases.map(row => { + const badge = STATUS[row.status] ?? STATUS[0]; + return ( +
  • +
    +
    + {row.title} + {row.key} + {badge.label} + {row.testPassId !== null && ( + pass {row.testPassId} + )} +
    + {row.roomName &&
    {row.roomName}
    } +
    +
    + {row.issueNumber !== null ? ( + <> + + #{row.issueNumber} + + + + ) : ( + + )} +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/README.md b/README.md index 62e54b1..9274448 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,19 @@ See [docs/architecture.md](docs/architecture.md) for the full mental model (project layout, request lifecycle, watch-mirror controller pattern, where to start contributing). +### Client API contracts + +Rec Room is shut down, so the clients can only be studied statically. +[docs/recroom-2023-client-api-complete.md](docs/recroom-2023-client-api-complete.md) +is the complete contract for the March-2023 client: every route it can +call, the HTTP verb, the request and response shapes, and a per-subsystem +diff against what this server currently implements. Read it before adding +or changing any endpoint that build touches — it also documents how to +recover verbs and response shapes from the disassembly yourself. + +The equivalent material for the December-2020 client is in the +`recroom-2020-client-*` files alongside it. + ### Microservices The production topology is the gateway-fronted compose stack. diff --git a/docs/admin.md b/docs/admin.md index 96cadbb..05546e3 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -56,7 +56,7 @@ that outer check. |---|---|---| | Overview | `/` | Live ops dashboard, online players, active sessions, quick kick/ban/broadcast actions | | Moderation | `/players` | Player directory, bans, reports, per-player ban/grant/gift/password/avatar/account actions | -| Activity | `/activity` | Admin audit log and per-player request logs | +| Activity | `/activity` | Admin audit log, per-player request logs, QA test cases + GitHub issue linking | | Content | `/rooms`, `/rooms/:id`, `/import-room`, `/content` | Room list/detail, room import, instances, leaderboards, community board, loading tips, 3D Charades word lists | | Operations | `/broadcast`, `/settings` | Server broadcast, server toggles (signups, everyone-is-friends, profanity filter, imported-room version clamp), signup codes, weekly challenges, Play menu tags, Rec Center doors, game config values | @@ -139,6 +139,109 @@ in S3 is modified. Toggle endpoint: `{"Disabled": bool}`. Clamped responses are served with a 60s edge TTL so flipping the toggle lands within a minute. +## Per-sub-room max players (`/rooms` → room detail) + +Player caps are two separate settings, and conflating them is the easy +mistake here: + +| Setting | Column | Scope | +| --- | --- | --- | +| `MaxCapacity` | `RoomEntity.MaxCapacity` | What the ROOM advertises; flows into `RoomInstance.MaxCapacity` for matchmaking and into the room-level `MaxPlayers` of the details payload. | +| `MaxPlayers` | `RoomSceneEntity.MaxPlayers` | What ONE sub-room caps itself at; appears per entry in the details `SubRooms[]`. | + +The room-level figure must not be read off sub-room 0 — that collapses the +two into one and makes an admin edit to a sub-room silently move the +room's advertised capacity. + +Sub-room caps have two write paths onto the same column: + +- **In game** (room owner only) — the "Max Player Count in This Subroom" + slider issues `PUT rooms/{id}/subrooms/{sub}/maxplayers`. Unlike most of + its siblings this one does **not** form-encode: `NLDBPDCNNCF.MNBPCGJNLNP` + (`Int64, Int64, Int32`) builds a `BLNOGFGHIIF` whose single JSON key is + `maxPlayers`, with verb 3 = PUT. It reads the reply back as + `FGCPNAACHIK`, so the response is the full room-details shape and has to + carry the new value. +- **Admin** (any room, ownership not required): + - `GET api/admin/v1/rooms/{id}/subrooms` — every sub-room with its cap, + matchmaking flag, sandbox flag and current data blob. Row ids go out as + **strings**; the SPA is JavaScript and mangles integers past 2^53. + - `PUT api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers` with + `{"MaxPlayers": int}`. `subRoomId` is the sub-room's **order index** — + the id the client and the rest of the wire use — not the row's primary + key. + +Both paths clamp to 1..80, matching the room-level cap in +`rooms/{id}/props`: 0 would make a sub-room unjoinable, and a shared range +keeps the two settings from disagreeing about what is a legal value. + +Enforcement caveat carried over from `RoomEntity.MaxCapacity`: this is the +advertised cap. The client never sets Photon `RoomOptions.MaxPlayers`, so +hard enforcement still needs a ClientMod Photon patch. + +## Test cases ↔ GitHub issues + +QA test cases can file and track GitHub issues. Configure with: + +| Key | Meaning | +| --- | --- | +| `GitHub:Token` | PAT with `issues:write` on the target repo | +| `GitHub:Repository` | `owner/name`, e.g. `DorkSquadRR/DorkNet` | +| `GitHub:ReconcileIntervalMinutes` | Sweep interval, default 15; `0` disables sweeps but leaves the manual endpoint working | + +**Without a token nothing breaks.** `IGitHubIssues.IsConfigured` is false, the +endpoints answer `503` with the reason, and the background reconciler logs once +and idles. A server with no GitHub credentials is a normal deployment. + +UI: **Activity → Test cases** (`/activity?tab=testcases`). File or unlink an +issue per case, or reconcile them all. The buttons disable themselves with an +explanation when GitHub is unconfigured rather than failing on click. + +Sub-room caps live on the room page: **Rooms → a room → Sub-rooms**. + +Endpoints (admin-gated — these are not part of the 2023 client's surface): + +- `POST api/testcasemanagement/v1/testcase/{id}/issue` — file and link. + **Idempotent**: a case already carrying a live issue returns that issue + instead of filing a duplicate, so sweeping a failing pass repeatedly is safe. + The issue body carries the description, key, room, test pass and tester + comments; labels are `qa` plus the case's own tags. +- `DELETE api/testcasemanagement/v1/testcase/{id}/issue` — unlink only. The + issue itself is left alone; closing someone's issue as a side effect of + tidying a QA link would be the wrong call. +- `POST api/testcasemanagement/v1/issues/sync` — reconcile now. The background + service runs the same code path on its interval, so manual and automatic + cannot drift. +- `GET api/admin/v1/testcases[?passId=&status=]` — the list the SPA renders, + with each case's issue number and link. + +Each of the three also answers under `/api/admin/v1/testcases/...`, because the +admin SPA speaks that prefix exclusively. + +### Where the link is stored + +In `TestCaseEntity.JiraBugUrl` — the field Rec Room's own QA tooling used for +the bug filed against a failing case. Reusing it means **no migration**, which +matters more here than the name: the Postgres path is `EnsureCreated`-only and +never replays migrations, so a new column has to be added twice (migration *and* +an idempotent `Ensure*` patch) or it is simply missing in production. A field +that already exists everywhere has neither problem, and the admin UI renders it +already. A leftover genuine Jira link is ignored rather than misparsed — the +issue number is only read from a URL matching the GitHub issue shape. + +### What the reconciler will and won't change + +| Issue | Case status | Result | +| --- | --- | --- | +| Closed | Failed | → Passed | +| Open | Passed | → Failed | +| anything | Claimed / NotYetTested | untouched | + +Closed means fixed: the case that was failing on that bug now passes, and +reopening the issue undoes it. Only those two states are the reconciler's to +move — a case a tester has Claimed, or one nobody has run yet, is never +rewritten underneath them. + ## Build And Deploy The service Dockerfile builds the admin SPA for service images that need diff --git a/docs/architecture.md b/docs/architecture.md index b55d9e2..d2837ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,8 +5,10 @@ before you touch code; it explains layout, request flow, the design patterns that look weird in isolation, and where to start. For deployment, see [`deploy.md`](deploy.md). For wire-protocol -reverse-engineering deliverables, see the `recroom-2020-client-*` -files in this folder. +reverse-engineering deliverables, see +[`recroom-2023-client-api-complete.md`](recroom-2023-client-api-complete.md) +(March-2023 client) and the `recroom-2020-client-*` files in this folder +(December-2020 client). --- @@ -253,6 +255,23 @@ client-side endpoints, DTOs, and request expectations extracted from the same decompile. They're the ground truth when a controller needs a new endpoint. +[`docs/recroom-2023-client-api-complete.md`](recroom-2023-client-api-complete.md) +is the equivalent for the March-2023 client, and additionally carries a +per-subsystem diff of client expectation against current server behaviour. +Three things account for nearly every defect it catalogues, so check all +three whenever you touch a 2023 endpoint: + +- **The verb.** It is a `BestHTTP.HTTPMethods` ordinal in the disassembly + (GET=0, HEAD=1, POST=2, PUT=3, DELETE=4, PATCH=5). A route registered + under the wrong verb is a 405 the client reports as a generic failure. +- **Scalar vs object.** Many endpoints return a BARE scalar — the client + reads `1000`, not `{"Limit":1000}`. Wrapping it makes the deserializer + throw and the feature fails silently. +- **The body encoding.** The client sends nearly every write as + `application/x-www-form-urlencoded`. An action bound with `[FromBody]` + under `[ApiController]` rejects that with 415 before the handler runs. + Use `Binding/FormOrJsonModelBinder` rather than hand-rolling a reader. + --- ## Database story diff --git a/docs/recroom-2023-client-api-complete.md b/docs/recroom-2023-client-api-complete.md new file mode 100644 index 0000000..52bca8c --- /dev/null +++ b/docs/recroom-2023-client-api-complete.md @@ -0,0 +1,3624 @@ +# Rec Room 2023-03-21 client — complete API contract + +_Generated 2026-07-27 from the March-2023 client binary._ + +Rec Room shut down, so the client can no longer be observed against a live +server. Everything here was recovered statically from the shipped build and +cross-checked against the DorkNet server source. This is the reference for +making DorkNet answer every request the game makes. + +## Sources + +| What | Where | +|---|---| +| Client ISIL disassembly (contains the string literals) | `C:\tmp\recroom-2023-03-21-isil\IsilDump` | +| Client decompiled C# (Il2CppInterop proxies, no literals) | `C:\tmp\recroom-2023-03-21-decompiled` | +| Client package / metadata | `C:\tmp\recroom-2023-03-21-devdork-package`, `global-metadata.dat` | +| Nearest dump with real type layout (2023-06-21) | `Recnet-old\dist\RecRoom-2023.06.21-steam\il2cppdump\dump.cs` | +| Server implementation | `DorkNet.Server/Controllers/**`, `DorkNet.Services.*` | + +The API client layer lives in the `RecNet.Runtime` assembly. Route literals +appear there only in the ISIL disassembly — the decompiled C# has the type and +field layout but no strings, so both are needed. + +## How to read the client binary + +### Route literals + +Routes are moved into an argument register just before the call into the shared +HTTP dispatcher: + +``` +076 Move r8, "rooms/bulk" +``` + +Some routes are `String.Format` templates (`"role/{0}/{1}"`), so the literal is a +shape rather than a path. Grepping for the literal alone will mis-classify those +as missing — check the call site. + +### HTTP verbs + +The small integer moved into the neighbouring argument register is a +`BestHTTP.HTTPMethods` ordinal. Confirmed from the static constructor of +`RecNet.Runtime/HNLCIDLIIBO`, which builds a `Dictionary`: + +| Ordinal | Verb | +|---|---| +| 0 | GET | +| 1 | HEAD | +| 2 | POST | +| 3 | PUT | +| 4 | DELETE | +| 5 | PATCH | +| 6 | MERGE | + +A verb can be chosen at runtime. `rooms/bulk` preloads 2 (POST) and +conditionally swaps in 0 (GET) when the id count is under 100, so that one route +legitimately needs both verbs. Implementing only one silently breaks the other. + +### Response shapes + +The issuing method signature is the response contract. `Task>` means a +JSON array; `FGLDKEJLAKB` means the body is a **bare integer**, not +an object wrapping one. Bare-scalar endpoints are a recurring source of defects — +returning `{"Limit":1000}` where the client reads `1000` makes the deserialize +throw and the feature silently fail. + +Watch for a projection between the wire type and the method return type. +`comments/unreadcounts` returns `Dictionary` from the method, but the +wire payload is `List` — a `Func, Dictionary<...>>` +runs client-side over the deserialized array. + +### JSON key names + +Most `RecNet` DTO properties are obfuscated and their wire names come from +attribute metadata that the dumps do not render, so exact key spelling is often +**not recoverable statically**. Two things make this tractable: + +- Json.NET matches member names case-insensitively and ignores unknown members, + so casing is forgiving and emitting extra alias keys is inert. +- Some DTOs are *not* obfuscated. `RecNet.KickPlayerDTO` has real public fields + (`GameSessionId`, `PlayerIds`) and is serialized with Unity `JsonUtility`, + which writes field names verbatim. + +Where a key could not be proven, this document says so rather than guessing. + +## Coverage summary + +- **565** route literals traced across **13** subsystems + (**518** are real HTTP routes; the rest are cache keys, deeplink + prefixes, MIME types or notification topics). +- **527** endpoints diffed against the DorkNet server. +- **315** defects found: 180 degraded, 106 breaks-gameplay, 25 cosmetic, 4 none. + +Defect classes: + +| Status | Count | Meaning | +|---|---|---| +| `OK` | 210 | handler exists, verb and shape match | +| `SHAPE_MISMATCH` | 183 | wrong JSON shape, keys or body binding | +| `MISSING` | 82 | no handler — the client gets 404 | +| `VERB_MISMATCH` | 27 | route exists under a different verb — 405 | +| `STUB` | 16 | handler returns placeholder data or drops the write | +| `UNKNOWN` | 5 | could not be proven from the binary | +| `CASING_MISMATCH` | 2 | right shape, wrong key casing | +| `NOT_A_ROUTE` | 2 | literal is not an HTTP request | + +## Realtime (SignalR) surface + +Not every request is HTTP. The client opens a SignalR hub at `{0}hub/v1` +(`RecNet.Runtime/OMHPBIEHOHN`, negotiate + `hub/v1`). + +- Server→client messages arrive on the hub method named **`"Notification"`** + (singular) carrying a **single `string`** argument whose content is JSON + `{"Id":..., "Msg":...}`. The handler is registered as `Action`. + Sending `ReceiveNotification`, or passing an object instead of a string, + makes every push silently vanish. +- Client→server: `SubscribeToPlayers` / `UnsubscribeFromPlayers`, plus + `"SubscribeTo"` / `"UnsubscribeFrom"` prefixes composed at runtime. +- Presence pushes are routed by the friend graph server-side, so the + subscription calls are advisory rather than load-bearing. + +### Push dispatch keys — the numeric-vs-name trap + +The envelope's `Id` is looked up in a dispatch dictionary the client built +at startup. Handlers are registered two ways, and the difference decides +what the server must put in `Id`: + +- **Enum overload** — the client calls `Int32.ToString()` on the id and + registers ONLY that numeric string (`RecNet.Runtime/OMHPBIEHOHN.txt:662` + and `:733`). There is no enum-name companion key. +- **String overload** — registers exactly one bespoke name. + +Lookup is a plain case-sensitive `Dictionary.TryGetValue` that returns +silently on a miss (`OMHPBIEHOHN.txt:4052-4237`). A wrong key is not an +error anywhere — on the server, on the wire, or in the client log. The push +just never arrives, which makes this class of bug very easy to miss. + +So **send the number**, except for these six, which are string-keyed: + +| Id | Wire key | +|---|---| +| 11 SubscriptionUpdateProfile | `AccountUpdate` | +| 12 SubscriptionUpdatePresence | `PresenceUpdate` | +| 13 SubscriptionUpdateGameSession | `RoomInstanceUpdate` | +| 15 SubscriptionUpdateRoom | `RoomUpdate` | +| 90 ChatMessageReceived | `ChatMessageReceived` | +| 95 CommunityBoardUpdate | `CommunityBoardUpdate` | + +Ids the 2023 client registers NO handler for — sending them is a no-op: +4, 15 (numeric form), 21, **24 ModerationRoomBan**, 40, 85. Room bans must +therefore go out as `ModerationKick` (22) with `IsBan` set; the moderation +manager registers only 22, `ModerationUnkick` and 23 +(`RecNet.Runtime/FPIBGPIAOBI.txt:310,323,335`). + +Ids the client handles that this server never sends (dead client features, +available if wanted): 3, 5, 23, 32, 60, 110, 120, 121, plus a long tail of +string channels — `GoToFailure`, `PhotonAccessToken`, `AppVersionUpdate`, +`GameConfig.Refresh`, `ReputationUpdate`, `KeepsakeInstanceAdded/Removed`, +`RoomCurrencyCreated/Modified/Deleted`, `RoomCommentDeleted` and others. + +### Hub methods + +The client invokes exactly one server method: `SubscribeToPlayers`, with a +`{PlayerIds: List}` payload, fire-and-forget. The `SubscribeTo`/ +`UnsubscribeFrom` composition exists in the binary but has no callers in +this build, and `UnsubscribeFromPlayers` does not appear at all — the +server's extra hub methods are unused but harmless. Presence is routed +server-side by the friend graph, so these calls are advisory. + +### Negotiate + +`POST {notifyHost}/hub/v1/negotiate` with no `negotiateVersion` (protocol +version 0), so ASP.NET Core's default `MapHub` response satisfies it. Auth +is a standard `Authorization: Bearer` header on both negotiate and the +WebSocket upgrade — no query-string `access_token` handling is needed. + +## Already fixed + +These were found by this audit and corrected in the same pass. Each was +verified against the binary before the change, and the full test suite passes. + +| Endpoint | Was | Now | +|---|---|---| +| `GET role/moderator/{id}` | 404 on every remote player | route added alongside role/developer | +| `GET comments/unreadcounts` | object body where the client reads an array | returns a JSON array of per-room rows | +| `GET api/banappeal/generateCode` | POST-only (405) + object body | GET added; body is a bare JSON string | +| `POST api/ageverification/generateCode` | object body where the client reads a string | body is a bare JSON string | +| `POST api/clubreporting/v1/report` | read `message`; client sends `details` | reads `details` first | +| `POST api/screensharereports/v1/report` | `ReportedPlayerId`/`ImageName` never bound | binds the client keys; image kept in the report text | +| `POST api/playerwarnings` | GET-only (405) | create handler added, moderator-gated | +| `POST api/playerwarnings/acknowledge` | 400 — demanded a warningId the client never sends | no-arg call acknowledges the newest warning | +| `POST api/PlayerReporting/v1/instantKick` | raw JSON body never parsed — kicked nobody | reads the KickPlayerDTO body | +| `PUT player/photonregionpings` | parsed only the 2020 pair shape | also reads one field per region name | +| `POST player/notifydisconnect` | bound 2020 `otherPlayerId` only | also binds `PlayerId`/`RoomInstanceId` | +| `PUT player/{statusvisibility,vrmovementmode,avoidjuniors}` | echoed and discarded | persisted per player and read back on heartbeat | +| `GET econ/customAvatarItems/v1/itemOwnershipLimit` | object body where the client reads an int | bare JSON integer | +| `GET api/customAvatarItems/v1/isCreationAllowedForAccount` | object body where the client reads a bool | bare JSON boolean | +| `GET api/customAvatarItems/v2/fromCreator/{id}` | bare array; client reads {Results,TotalResults} | paged container | +| `POST econ/customAvatarItems/v1/{id}/purchase` | 404 — route only existed under api/ | econ/ alias added | +| `GET api/ugcPurchasables/v1/items/room/{roomId}` | 404 — only the ?roomId= form existed | path form added | +| `POST api/equipment/v1/update` | array body failed model binding (400) | accepts an array or a single object | +| `POST api/consumables/v1/transfer` | JSON body never read | reads JSON as well as form/query | +| `GET api/consumables/v1/getTransferable/{id}` | 404 | implemented | +| `PUT api/roomconsumables/.../{consume,purchase/tokens,purchase/currency}` | POST-only (405) | PUT added — consumables are buyable again | +| `Room consumable descriptors` | nested PriceAndCurrency; client reads flat | flat Price/PurchaseCurrencyId/ModifiedAt | +| `Room currency + purchase-offer wires` | server-invented key names | client key names, legacy aliases retained | +| `GET api/roomcurrencies/v1/getBalance` | ignored `accountId`, so always answered for the caller | honours `accountId` | +| `api/roomcurrencies createCurrency/updateCurrency` | `Limit` dropped | `Limit` accepted | +| `GET api/roomkeys/v1/mine` | returned keys CREATED, not held | returns purchased keys (plus own) | +| `GET api/roomkeys/v1/purchased/{id}` | asked "did I buy it" — always false for the creator | asks "did anyone buy it" | +| `GET api/roomkeys/v1/owns` | ignored `playerId` | answers for the named player | +| `POST api/roomkeys/v1/owns/bulk` | JSON pair array never read | reads {RoomKeyId, AccountId} pairs | +| `PUT api/roomkeys/v1/update{All,Name,Description,Price}` | 405 — none registered | all four added | +| `13 club write endpoints` | 415 — [FromBody] vs the client's form bodies | shared form-or-JSON model binder | +| `PUT club/{id}/clubhouse` | read the query only, so setting a clubhouse CLEARED it | reads the form body | +| `PUT club/{id}/members/requesttojoin` | joinability 1 and 2 swapped | Open=0, InviteOnly=1, AskToJoin=2 | +| `POST thread/{id}/leave` | DELETE-only (405) | POST added | +| `PUT announcements/club/{cid}/{aid}` | bound to the DELETE handler — editing DELETED the post | its own edit handler | +| `POST announcements/club/{cid}` | 404 — no create route | implemented, returns the new id | +| `POST api/inventions/v4/addversion` | 400 — client sends `inventionDataFilename` | accepts it plus the extra cost fields | +| `POST api/inventions/v1/settags` | 400 — tag arrays bound to string | accepts string arrays | +| `POST api/images/v1/modifyaccessibility` | `Accessibility` never read; photos stayed private | reads the enum | +| `POST leaderboard/CheckAndSetStat` | 404 — every 2023 score write was lost | implemented with compare-and-set | +| `api/externalfriendinvite/v1/get*referrers` | objects where the client reads a bare id array | bare Int32 array | +| `GET account/search` | bound `query`; client sends `name` — always empty | accepts both | +| `PUT account/me/birthday` | POST-only (405) | PUT added | +| `DELETE cachedlogin/current` | GET-only (405) | DELETE added | +| `PUT api/players/v4/current/contact` | GET-only (405) | PUT added | +| `PUT roominstance/{id}/markprivate` | POST-only (405) | PUT added | +| `PUT roominstance/{id}/roomCode` | 405, and custom codes were discarded | PUT added; codes persist | +| `GET roomserver/rooms/base, roomserver/featuredrooms/current` | 404 — no roomserver/ twin | twins added | +| `GET featuredrooms/current` | no `RoomName` key — tile captions were null | `RoomName` emitted | +| `playlists name/description/image/tags` | 415 — [FromBody] vs form bodies | form/query/JSON reader | +| `playlists accessibility/levelvoting/restrictions/warning` | PUT 405, and values were discarded | PUT added and values persisted | +| `SignalR push `Id` (notify)` | enum NAME sent for most ids; client only registered numbers — pushes vanished | numeric by default, with the six string-keyed ids listed explicitly | +| `SignalR SubscriptionUpdateRoom` | sent as `SubscriptionUpdateRoom`; client listens on `RoomUpdate` | sent as `RoomUpdate` | +| `Room ban notification` | sent id 24, for which the client has no handler | sent as ModerationKick (22) with IsBan | +| `Presence on SignalR reconnect` | disconnect pushed offline; nothing ever pushed back online, so a reconnect greyed the player out for the rest of the session | connect re-pushes presence when a room is already known | + +Everything else in the per-subsystem Defects sections below is still +outstanding. + +## Subsystems + +### Identity, accounts, login and version gate + +`identity-account` + +Identity-account diff for the 2023-03-21 client vs DorkNet (all claims verified by reading both the server source and the ISIL). 14 defects across 34 checked route/verb pairs. Hard failures (404/405): DELETE /account/me, PUT /account/me/birthday (server POST-only), account/me/{emoji,personalpronouns,identityflags,bannerimage,confirmphone} missing, POST account/bulk/{email,phone} missing, GET accounts/{id}/receives/{cat} missing, GET platformid/{id} missing (nearest route is wrong path + object-vs-bare-string + hardcoded 0), DELETE /cachedlogin/current (GET-only), PUT api/players/v4/current/contact (GET-only). Silent-empty/wrong-data defects: account/search binds 'query' but client sends 'name' → search always returns []; cachedlogin/forplatformids reads 'platformIds' but client sends repeated 'id' → always []; cachedlogin/migrate binds platform/platformId vs client's legacyPlatformId/newPlatformId → no-op; account/me/createlogintoken returns an object where the client requires a bare JSON string → reader throws; GET /account/me hardcodes email/phone/birthday and omits availableUsernameChanges/displayEmoji/bannerImage/personalPronouns/identityFlags; connect/token lacks login_token and device_code grants (both fall into the device-id fallback → wrong-account or approval-bypassed logins), and account/me/remoteauth validates against the wrong token store so passwordless approval always 401s. Clean: haspassword, changepassword, recoverpassword, bulk (repeated 'id' handled), {id}/bio, privacy settings GET/PUT, sanitize v1 + isPure (request field names UNKNOWN — JsonUtility, no literals), versioncheck/v4, isactivecreator, forplatformid, deviceauthorization shape, eac/challenge, namegen, parentalcontrol, and the username/displayname/bio PUTs. account/{id}/clubs works but omits MinLevel and ClubChatEnabled (defaults 0/false — club chat renders disabled). + +**Client-side notes.** SERIALIZER MECHANICS (applies to every DTO above) +- The 2023 client deserializes API responses with Utf8Json-style generated formatters (writer type LMANJAHJEKC, reader type EILLEGCDDNJ, key automaton PIPOGPCBCNM). Each formatter's .ctor registers every property under THREE exact byte keys — PascalCase, camelCase and all-lowercase (e.g. AEGCKMKICFG.txt:855/866/874 -> "AccountId"/"accountId"/"accountid"). Matching is exact-byte per variant, so a fourth spelling does NOT match: DorkNet's `"username"` is fine (registered), but a hypothetical `"Username"` would NOT be (only "UserName"/"userName"/"username" are registered). Unknown keys are skipped; missing keys leave CLR defaults, so a missing key is safe but a wrong-cased key silently yields null/0. +- Danger cases: non-nullable value types. MIMFEOHELNC.CreatedAt and MDGJJCFGJNF.LastLoginTime are `System.DateTime` (not nullable) — emitting `null` for those keys will throw in the reader, whereas omitting the key is harmless. +- Request parameters go through BNDIAONDFFF.AFGEDDANEKP(name, value), which stores List> and only decides the encoding at send time: GET => joined with "&" and appended after "?" (BNDIAONDFFF.txt:2806-2851), POST/PUT => HTTPFormBase.AddField (urlencoded/multipart) (BNDIAONDFFF.txt:3032/3099). Servers should accept both query and form for the POST/PUT routes. +- Verb enum is BestHTTP.HTTPMethods: 0=Get, 1=Head, 2=Post, 3=Put, 4=Delete, 5=Patch (validated against call sites: account/me DELETE=4, changepassword POST=2, bio/username PUT=3). +- Host is the SECOND ctor arg (GJDLNNLKDIJ) of BNDIAONDFFF..ctor(HTTPMethods, GJDLNNLKDIJ, String) — BNDIAONDFFF.txt:74. Observed ordinals in this subsystem: 0 for connect/*, cachedlogin/*, eac/challenge, account/me/{haspassword,changepassword,createlogintoken,remoteauth}, account/recoverpassword, platformid/{0} (auth service); 1 for api/* and the CBKANFIOBCF-based clients (api service, hard-coded at CBKANFIOBCF.txt:5174); 10 for account/*, accountprivacysettings/*, parentalcontrol/me, namegen/options (accounts service); 13 for account/{id}/clubs; 15 for accounts/{id}/receives/{category}. The ordinal->URL table is built at runtime from config (PAPLNIPKAMG.OICOPGCHJAG, Dictionary at PAPLNIPKAMG.txt:2044), so the literal hostnames are NOT in the binary; the family grouping is the evidence. DorkNet serves these routes host-agnostically today, so this only matters for hosts-file/DNS coverage. + +DTO NOTE +- MIMFEOHELNC has 13 properties but only 12 serialized keys (AEGCKMKICFG.txt "Compare rax, 11" at :2665 confirms indices 0..11). The unserialized one is the extra ObscuredString at MIMFEOHELNC.txt:195 (HDLPBPJEJNC) — which property name it carries is UNKNOWN. Same pattern in MDGJJCFGJNF (6 keys, 7 properties; the trailing MIMFEOHELNC Account field is client-only). + +GAPS vs the current DorkNet server (compared against the server's reflected route table) +1. `DELETE /cachedlogin/current` — client sends DELETE (LBNJFPOLCDL.txt:4190); server registers only GET (DorkNet.Server\Controllers\Auth\AuthController.cs:328) => 405 on sign-out. +2. `DELETE /account/me` — client sends DELETE (OPMAPIOEIFG.txt:8773); server only has GET (Controllers\Accounts\AccountsController.cs:54). +3. `GET /platformid/{accountId}` — not registered at all; the nearest server route is `/account/v1/{accountId}/platformid` and it returns an OBJECT `{ PlatformId = 0L }` (AccountsController.cs:684) while the client expects a BARE JSON STRING. +4. `GET /accounts/{accountId}/receives/{category}` — not registered anywhere => 404, the promise in AccountSpecificUIBehaviour rejects. +5. `POST /account/bulk/email` and `POST /account/bulk/phone` — not registered (server only has GET/POST /account/bulk). +6. `POST /cachedlogin/forplatformids` — field-name mismatch: client sends repeated `id=` (OPMAPIOEIFG.txt:11383), server reads `platformIds`/`PlatformIds` (AuthController.cs:289-311) => always returns []. +7. `POST /cachedlogin/migrate` — field-name mismatch: client sends `legacyPlatformId` + `newPlatformId` (LBNJFPOLCDL.txt:4469/:4479), server binds `[FromForm] platform, platformId` (AuthController.cs:342). +8. `account/me/` suffixes missing on the server: emoji, personalpronouns, identityflags, bannerimage, confirmphone (server has username/displayname/bio/birthday/email/phone/profileimage only) => "Failed to modify ". +9. account/me payload: DorkNet's RecNetSelfAccount (DorkNet.Models\Auth\RecNetResult.cs:48-115) emits accountId/username/displayName/profileImage/isJunior/hasBirthday/platforms/rawUsername/email/phone/birthday/juniorState/parentAccountId. Keys that exist for this client but are never emitted: availableUsernameChanges (Int32 — 0 means "no name changes left" in the UI), displayEmoji, bannerImage, treatAsJunior, personalPronouns, identityFlags, createdAt. `platforms` and `rawUsername` are ignored by the 2023 reader. +10. cachedlogin payloads: server's CachedLogin (DorkNet.Models\Auth\CachedLogin.cs) matches 5 of 6 keys; `refreshToken` is never emitted (harmless — String stays null). + +FLAGGED NON-ROUTE / PREFIX LITERALS +- "account/me/" and "account/bulk/" are route PREFIXES, concatenated with a suffix before use (OPMAPIOEIFG.txt:8579 and :12121). They are real routes only in their concatenated forms, enumerated in the entries above. +- "account/{0}/bio" appears twice; the occurrence at OPMAPIOEIFG.txt:6239 is NOT an HTTP request — it builds a response-cache key passed to LBIIOPNIDAC.PBGNNIJLBDG (cache invalidation after PUT account/me/bio). The other occurrence (:10822) is a genuine GET, so the literal is still marked isRealHttpRoute=true. +- accounts/{0}/receives/{1}: the {1} segment is an enum boxed and String.Format'ed, i.e. the enum MEMBER NAME. The constant is 8 (LELAJKMOMIA.txt:693) but the CCOKJMJOIJF member names are not present as literals anywhere in the ISIL — the server should treat that segment as an opaque string. + +#### Endpoints + +| Verb | Route | Request | Response | +|---|---|---|---| +| GET (0) when id count <= 100, POST (2) above that — chosen at runtime by ALHIJCJOLCB.JIECAFGCODK(count, threshold=100) | `account/bulk` | repeated "id" (Int32) — query params on GET, urlencoded form fields on POST | JSON array of MIMFEOHELNC (same shape as account/search) | +| POST (2) | `account/bulk/` | path is "account/bulk/" + segment, giving the two concrete routes account/bulk/email and account/bulk/phone. Body: repeated field named after the same word ("email" or "phone"), ea | JSON array of JFFGMHMHMCO = MIMFEOHELNC fields plus a leading ContactDetail(String): keys ContactDetail, AccountId(Int32), UserName, DisplayName, DisplayEmoji, ProfileImage, BannerImage, TreatAsJunior(Boolean), HasBirthd | +| GET (0) | `account/isactivecreator/me` | none | bare JSON boolean — Task | +| GET (HTTPMethods 0) — also DELETE (4) from a second call site | `account/me` | none (GET). DELETE variant also sends no body. | CAALNOGGDLG ("SelfAccount") object. Reader keys registered in formatter ctor, index order: Email(String), Phone(String), Birthday(Nullable), JuniorState(enum EJFPLBEEAMN as Int32), ParentAccountId(Nullable". | +| POST (2) | `account/me/changepassword` | form fields: "oldPassword" (String), "newPassword" (String) | body ignored (LDGADANDBIO promise); status only | +| POST (2) | `account/me/createlogintoken` | none (no fields on either call site) | bare JSON string (the login token) — FGLDKEJLAKB | +| GET (0) | `account/me/haspassword` | none | bare JSON boolean (true/false) — Task | +| POST (2) | `account/me/remoteauth` | form field "code" (String); request timeout overridden via BNDIAONDFFF.EHPIOGADENJ | body ignored (LDGADANDBIO); failure string "Failed to authorize login" | +| POST (2) | `account/recoverpassword` | form field "email" (String) | body ignored (LDGADANDBIO); failure string "ResetPassword failed" | +| GET (0) | `account/search` | query "name" (String) | JSON array of MIMFEOHELNC: [{AccountId(Int32), UserName(String), DisplayName(String), DisplayEmoji(String), ProfileImage(String), BannerImage(String), TreatAsJunior(Boolean), HasBirthday(Boolean), PersonalPronouns(Int32 | +| GET (0) | `account/{0}/bio` | {0} = accountId (Int32) | CCNKBIGMLGP object: {"AccountId": Int32, "Bio": String} (also accepted: accountId/accountid, bio) | +| GET (0) | `account/{0}/clubs` | {0} = accountId (Int32) | JSON array of FOIJDINBPFG: ClubId(Int64), Name(String), Description(String), MainImageName(String), State(enum IACDINKNHKB as Int32), CreatorAccountId(Int32), Category(String), Visibility(enum GJLKOMJKCFL as Int32), Join | +| PUT (3) | `accountprivacysettings/recenthistoryvisibility` | form field "isRecentHistoryVisible" (Boolean) | body ignored (LDGADANDBIO); failure string "Failed to modify accountprivacysettings/recenthistoryvisibility" | +| GET (0) | `accountprivacysettings/{0}` | {0} = accountId (Int32) | EBPCGLAICIH object: {"AccountId": Int32, "IsRecentHistoryVisible": Boolean} (Pascal/camel/all-lower all accepted). A second client method reads the same route and projects it to a bare Boolean. | +| GET (0) | `accounts/{0}/receives/{1}` | {0} = accountId (Int32). {1} = a CCOKJMJOIJF enum value boxed then String.Format'ed, i.e. the enum MEMBER NAME, not the number; the constant passed at this call site is value 8. Th | bare JSON boolean (Action continuation) | +| PUT (3) | `api/players/v4/current/contact` | query "email" (String) + raw JSON body of OPMAPIOEIFG/BIIIACIMPBM: {MarketingEmails: Boolean, InviteEmails: Boolean, GameClientTextInvites: Boolean, FriendRequestEmails: Int32-enum | body ignored (KDOPJCNKOOK = send-and-discard); failure string "Failed to update player marketing email prefs" | +| POST (2) | `api/sanitize/v1` | raw JSON body produced by UnityEngine.JsonUtility.ToJson(HPKNELJLNOJ/PurifyStringRequest). JsonUtility serializes FIELD names; those field names are not present as literals in the | bare JSON string — the purified text (continuation is System.Action`1, wrapped client-side into HPKNELJLNOJ/KKMLDOEOADJ(Boolean, String)) | +| POST (2) | `api/sanitize/v1/isPure` | raw JSON body from JsonUtility.ToJson(HPKNELJLNOJ/IsStringPureRequest) — four fields set at :481-489 (two reference fields + two int/bool fields). Field names UNKNOWN (JsonUtility | PJLCFFHLNAE object: {"IsPure": Boolean} (also "isPure"/"ispure") | +| GET (0) | `api/versioncheck/v4` | query params: "v" (client version, from PAPLNIPKAMG version field), "p" (platform, Int32), "pid" (Int32) | HABAPHJMBEO/BFEAELMKAKM object: {"VersionStatus": Int32 (enum PHOGEIPMHOK), "UpdateNotificationStage": Int32 (enum MEHPMLBHDOJ)} — also versionStatus/versionstatus, updateNotificationStage/updatenotificationstage | +| DELETE (4) | `cachedlogin/current` | none | response body deserialized through the generic FDKKOPAPDGF path but discarded by the caller (method returns LDGADANDBIO, a value-less promise) — treat as "status only" | +| GET (0) | `cachedlogin/forplatformid/{0}/{1}` | {0} = platform as Int32 (enum HHJIBNMLOAC converted to int before boxing), {1} = platformId (String) | JSON array of LBNJFPOLCDL/MDGJJCFGJNF: {Platform: Int32 (enum HHJIBNMLOAC), PlatformId: String, AccountId: Int32, LastLoginTime: DateTime (non-nullable), RequirePassword: Boolean, RefreshToken: String} — each key also in | +| POST (2) | `cachedlogin/forplatformids` | repeated form field "id" (String) — one entry per platform id | JSON array of LBNJFPOLCDL/MDGJJCFGJNF (same shape as cachedlogin/forplatformid); mapped client-side into List | +| POST (2) | `cachedlogin/migrate` | form fields: "legacyPlatformId" (String), "newPlatformId" (String) | body ignored (KDOPJCNKOOK); status only | +| POST (2, urlencoded form via PAPLNIPKAMG.NKGALKBJBJG) | `connect/deviceauthorization` | form: client_id="recroom", client_secret="VxZ53kgbbEaRoZAeMe00MagtgD12GLL2" | LBNJFPOLCDL/BENCODMLKEA object, write keys snake_case: {"device_code": String, "user_code": String, "verification_uri": String, "verification_uri_complete": String, "expires_in": Int32, "interval": Int32}. Reader also ac | +| POST (2, urlencoded form via PAPLNIPKAMG.NKGALKBJBJG) | `connect/token` | OAuth-ish form. Always added by LBNJFPOLCDL.CONAGEGDJAJ: client_id="recroom", client_secret="VxZ53kgbbEaRoZAeMe00MagtgD12GLL2", platform, platform_id, device_id, device_class, time | LBNJFPOLCDL/HBEJOJNIMBD object, write keys: {"Error": String, "error_description": String, "access_token": String, "refresh_token": String, "Key": String}; reader also accepts error/Error_description/Access_token/Refresh | +| GET (0) — issued through PAPLNIPKAMG.LDBMKNCNKGJ which passes verb 0 | `eac/challenge` | none | bare JSON string — the base64 EAC challenge (FGLDKEJLAKB). Must be valid base64: the client feeds it to EACManager challenge-response generation. | +| GET (0) | `namegen/options` | none | OPMAPIOEIFG/EAHBKJEMHEM object: {"Nouns": List, "Adjectives": List} — also nouns/adjectives (2 casings only, camel==lower) | +| GET (0) | `parentalcontrol/me` | none | MFBCJHHEMKF object: {"AccountId": Int32, "DisallowInAppPurchases": Boolean} (also accountId/accountid, disallowInAppPurchases/disallowinapppurchases). One caller projects the same response down to a bare Boolean. | +| GET (0) | `platformid/{0}` | {0} = accountId (Int32); the client short-circuits and never sends the request when accountId <= 0 | bare JSON string — FGLDKEJLAKB (the platform id). NOT an object. | + +#### Defects + +##### `GET account/me` — SHAPE_MISMATCH (degraded) + +Handler exists and returns RecNetSelfAccount (DorkNet.Models\Auth\RecNetResult.cs:98). Global serializer PropertyNamingPolicy=null (Startup\ServiceCollectionExtensions.cs:380-381) with JsonPropertyName attrs, so it emits accountId, username, displayName, profileImage, isJunior, platforms, rawUsername, hasBirthday, createdAt, email, phone, birthday, juniorState, parentAccountId. All of those hit registered 2023 reader keys (accountId/username/displayName/profileImage/hasBirthday/createdAt/email/phone/birthday/juniorState/parentAccountId; 'isJunior' lands on the client's Nullable IsJunior). Keys the 2023 CAALNOGGDLG reader registers but the server NEVER emits: AvailableUsernameChanges (Int32 -> defaults 0 = UI shows no username changes remaining), DisplayEmoji, BannerImage, PersonalPronouns, IdentityFlags, TreatAsJunior (server's TreatAsJunior property serializes under the key 'isJunior', so the client's TreatAsJunior bool stays false — benign). Also the values are hardcoded: Email/Phone always string.Empty, Birthday always 2000-01-01, JuniorState always 0 — the real PlayerEntity columns (Email, Phone, Birthday set by SetEmail/SetPhone/SetBirthday in this same controller) are not read back, so the settings screen never shows what the player saved. + +Handler: `DorkNet.Server\Controllers\Accounts\AccountsController.cs:54` + +**Fix.** In AccountsController.GetMe, build the payload from the actual PlayerEntity (Email, Phone, Birthday, IsJunior) and add availableUsernameChanges (e.g. a large int), displayEmoji, bannerImage, personalPronouns (0), identityFlags (0) keys — extend RecNetSelfAccount or return an augmented DTO for the 2023 path. + +##### `DELETE account/me` — VERB_MISMATCH (degraded) + +Client's in-game account-deletion action sends DELETE /account/me (OPMAPIOEIFG.txt:8771-8773, verb 4). Server registers only [HttpGet] on /account/me (plus v1/v2 GET aliases) — DELETE returns 405 and the client logs 'Failed to delete local account'. + +Handler: `DorkNet.Server\Controllers\Accounts\AccountsController.cs:54` + +**Fix.** Add a [HttpDelete("/account/me")] action in AccountsController that (soft-)deletes/anonymizes the caller's PlayerEntity and returns 200; body is ignored by the client. + +##### `PUT account/me/birthday` — VERB_MISMATCH (degraded) + +The 2023 client sends PUT (verb 3 — verified at OPMAPIOEIFG.txt:7950-7954: suffix 'birthday', rcx=3) but the server registers only [HttpPost("/account/v1/birthday")] and [HttpPost("/account/me/birthday")] → 405, client shows 'Failed to modify birthday'. This breaks the junior/age-gate birthday prompt. + +Handler: `DorkNet.Server\Controllers\Accounts\AccountsController.cs:341` + +**Fix.** Add [HttpPut("/account/me/birthday")] to the existing SetBirthday action in AccountsController. + +##### `PUT (emoji/personalpronouns/identityflags/bannerimage), POST (confirmphone) account/me/emoji, account/me/personalpronouns, account/me/identityflags, account/me/bannerimage, account/me/confirmphone` — MISSING (degraded) + +None of these five concrete routes exist. server-routes.txt has no account/me/emoji|personalpronouns|identityflags|bannerimage|confirmphone; confirmphone exists only as POST /account/v1/confirmphone (AccountsController.cs:332) and api/account/v1/confirmphone — the 2023 client concatenates 'account/me/' + suffix (OPMAPIOEIFG.txt:8579), so it 404s. Result: profile-emoji / pronoun / identity-flag / banner-image edits and phone confirmation all fail with 'Failed to modify '. + +**Fix.** Add handlers in AccountsController: [HttpPut] /account/me/emoji (form 'emoji'), /account/me/personalpronouns (form 'personalpronouns', int), /account/me/identityflags (form 'identityflags', int), /account/me/bannerimage (form 'bannerimage'); [HttpPost] /account/me/confirmphone (route the existing ConfirmPhone action). Persist emoji/bannerimage/pronouns/flags on PlayerEntity so GET /account/me can echo them back (required for the account/me shape fix above). + +##### `POST account/me/createlogintoken` — SHAPE_MISMATCH (degraded) + +Client deserializes the response as a BARE JSON string (FGLDKEJLAKB, LBNJFPOLCDL.txt:8382) — server returns an OBJECT { Success, Error, Token, LoginToken, ExpiresAt } (AccountsController.cs:575-582). The strict reader hits '{' where it expects '"' and throws → 'Failed to CreateLoginToken', so every 'open on the web' link (BHKIHEEBBNG builds auth.rec.net URL + token) fails. + +Handler: `DorkNet.Server\Controllers\Accounts\AccountsController.cs:565` + +**Fix.** Return the token as a bare JSON string: Content(JsonSerializer.Serialize(token), "application/json"). Keep the token-minting/PlayerSettings persistence as-is. + +##### `GET account/search` — SHAPE_MISMATCH (degraded) + +REQUEST param name mismatch: the 2023 client sends the search text as query key 'name' (verified in ISIL: OPMAPIOEIFG.txt:12493 'Move rdx, "name"' into AFGEDDANEKP), but the server binds [FromQuery] string? query — 'name' is never read, query stays null, and line 180 short-circuits to an empty array. Watch player search ALWAYS returns zero results for this client. Response shape itself is fine (List of RecNetAccount, camelCase keys all registered by MIMFEOHELNC's reader; DisplayEmoji/BannerImage/PersonalPronouns/IdentityFlags omitted → CLR defaults, cosmetic). + +Handler: `DorkNet.Server\Controllers\Accounts\AccountsController.cs:175` + +**Fix.** In Search, also accept the 'name' query key: e.g. add [FromQuery] string? name and use query ?? name (mirror the existing multi-alias pattern used elsewhere in the controller). + +##### `POST account/bulk/email + account/bulk/phone` — MISSING (degraded) + +The 2023 friend-finder contact import POSTs account/bulk/email and account/bulk/phone with repeated 'email'/'phone' form fields (OPMAPIOEIFG.txt:12121 concat, :12143 body key). No such routes exist — server only has /account/bulk (exact) and /account/v1/emails|phones — so both 404 and the contact-import flow dies. Response must be a JSON array of JFFGMHMHMCO = MIMFEOHELNC fields plus a leading ContactDetail(String) per matched account. + +**Fix.** Add [HttpPost("/account/bulk/email")] and [HttpPost("/account/bulk/phone")] in AccountsController: read the repeated 'email'/'phone' form fields, match against Players.Email/Phone (logic already exists in Bulk()), and return objects of shape { ContactDetail = , ...BuildAccount fields }. + +##### `GET accounts/{0}/receives/{1}` — MISSING (degraded) + +No route with an 'accounts/' + 'receives' segment exists anywhere in DorkNet.Server (grep over Controllers confirms; no catch-all remains — GlobalCatchAllController was deleted). Client GETs accounts/{accountId}/receives/{enumMemberName} expecting a bare JSON boolean; the 404 rejects the promise in AGUI AccountSpecificUIBehaviour and blocks the notification-gated UI action. + +**Fix.** Add GET /accounts/{accountId:long}/receives/{category} (category as an opaque {category} string segment — the enum member name is not recoverable, do NOT constrain it) returning Content("true","application/json"), in PlatformNotificationsController or AccountsController. + +##### `DELETE cachedlogin/current` — VERB_MISMATCH (degraded) + +Client sign-out / 'forget this account' sends DELETE /cachedlogin/current (LBNJFPOLCDL.txt:4190, verb 4); server registers only [HttpGet] → 405 and the forget-account promise rejects. + +Handler: `DorkNet.Server\Controllers\Auth\AuthController.cs:328` + +**Fix.** Add [HttpDelete("/cachedlogin/current")] — resolve the bearer's player, clear its LastPlatform/LastPlatformId (so GetCachedLoginsAsync stops returning it), return 200. + +##### `POST cachedlogin/forplatformids` — SHAPE_MISMATCH (degraded) + +REQUEST field mismatch: the 2023 client sends the platform ids as repeated form field 'id' (OPMAPIOEIFG.txt:11383) with NO 'platform' field; the server reads only Form/Query 'platformIds'/'PlatformIds' (AuthController.cs:300-311) → rawIds empty → always returns [] and Steam-friend → Rec Room account resolution silently yields nothing ('Failed to GetAccountsFromPlatformIds' path never even errors — it just gets an empty list). + +Handler: `DorkNet.Server\Controllers\Auth\AuthController.cs:289` + +**Fix.** Also read Form["id"]/Form["Id"] (ASP.NET comma-joins repeated fields; split on ',') in CachedLoginsForPlatformIds, defaulting platform to 0/any when absent. + +##### `POST connect/token` — SHAPE_MISMATCH (degraded) + +Route/verb/response OK: urlencoded POST accepted; BuildTokenResponse (AuthController.cs:36-62) emits access_token, refresh_token, Key/key — all registered by the HBEJOJNIMBD reader; error/error_description emitted on failures. Grant-type coverage is the defect: only cached_login, refresh_token and password(+device fallback) are implemented. (a) grant_type=login_token (+account_id, login_token from createlogintoken) falls through to the device-id fallback and issues a token for whatever account the deviceId maps to, IGNORING account_id and never validating the login token — wrong-account sign-in. (b) grant_type=urn:ietf:params:oauth:grant-type:device_code falls through the same way — the polling device gets a device-bound token immediately, bypassing the remote-approval flow entirely (and 'authorization_pending' retry semantics are never exercised). (c) grant_type=create_account also lands in the fallback, which happens to create-or-fetch by deviceId — acceptable behaviorally. + +Handler: `DorkNet.Server\Controllers\Auth\AuthController.cs:92` + +**Fix.** In AuthController.Token add explicit branches: login_token → validate against the remote_login_token PlayerSettings row for the supplied account_id and issue that account's pair; device_code → look up the deviceauthorization:{device_code} row, return {error:"authorization_pending"} (HTTP 400) until RemoteAuth marks it approved, then issue the approving account's pair. + +##### `GET platformid/{0}` — MISSING (degraded) + +GET /platformid/{accountId} is not registered anywhere (server-routes.txt has no bare 'platformid/' entry; no catch-all remains) → 404, and platform-native profile/invite actions from BaseAccountModel reject. The nearest route, GET /account/v1/{accountId:long}/platformid (AccountsController.cs:684-685), is BOTH the wrong path AND the wrong shape — it returns the object { PlatformId = 0L } where the client expects a BARE JSON string, and its hardcoded 0 marks it a stub besides. + +**Fix.** Add [HttpGet("/platformid/{accountId:long}")] returning Content(JsonSerializer.Serialize(player.LastPlatformId ?? ""), "application/json") — a bare JSON string of the real stored platform id. Fix the /account/v1/.../platformid stub to return the real value too. + +##### `GET account/{0}/clubs` — SHAPE_MISMATCH (cosmetic) + +GET /account/{playerId:long}/clubs exists with a real ClubsForPlayerAsync query. ToWireClub (ClubsController.cs:937-953) emits PascalCase ClubId, Name, Description, MainImageName, State, CreatorAccountId, Category, Visibility, Joinability, AllowJuniors, MemberCount, IsRRO, ClubhouseRoomId, ClubType — all registered by FOIJDINBPFG's reader. Two registered keys are never emitted: MinLevel (Int32 → defaults 0, harmless) and ClubChatEnabled (Boolean → defaults false, so the profile Clubs tab renders every club with chat disabled). + +Handler: `DorkNet.Server\Controllers\Clubs\ClubsController.cs:318` + +**Fix.** Add MinLevel = 0 (or the entity value) and ClubChatEnabled = true/entity value to ToWireClub in ClubsController.cs. + +##### `PUT api/players/v4/current/contact` — VERB_MISMATCH (cosmetic) + +Server registers only [HttpGet] on /api/players/v4/current/contact; the 2023 client PUTs it (query 'email' + JSON prefs body) right after POST account/me/email → 405. The client discards the body (fire-and-forget) so the only visible symptom is the 'Failed to update player marketing email prefs' log line and the prefs never persisting. + +Handler: `DorkNet.Server\Controllers\API\Players\V2\PlayersController.cs:24` + +**Fix.** Add [HttpPut("/api/players/v4/current/contact")] (can share the action or a new no-op that returns 200; optionally persist the email query param / pref booleans). + +##### `POST cachedlogin/migrate` — SHAPE_MISMATCH (cosmetic) + +REQUEST field mismatch: client sends 'legacyPlatformId' + 'newPlatformId' (LBNJFPOLCDL.txt:4469/:4479); server binds [FromForm] platform, platformId → binds 0/null and TagPlatformAsync writes nothing meaningful. No migration happens. Cosmetic for the PC preservation target since the caller is IOSPlatformManager (mobile first-launch id change), but the handler is effectively a silent no-op for this client. + +Handler: `DorkNet.Server\Controllers\Auth\AuthController.cs:342` + +**Fix.** Bind [FromForm(Name="legacyPlatformId")] and [FromForm(Name="newPlatformId")]; re-tag rows whose LastPlatformId == legacyPlatformId to newPlatformId. + +### Matchmaking, sessions and presence + +`matchmaking-session` + +All 20 client routes have real, verb-matching handlers on DorkNet (GoToController / MatchController / MatchPlayerController) and every matchmake/* response uses the camelCase MatchmakingResponseDto/RoomInstanceDto shape (GoToController.cs:1529-1634) that carries every key the 2023 client reads, plus the heartbeat PresenceDto with isOnline and appVersion "20230317" (MatchPlayerController.cs:485-511). No missing endpoints and no response-shape defects that break gameplay. Verified defects: (1) player/notifydisconnect binds 2020's otherPlayerId instead of 2023's PlayerId/RoomInstanceId form keys (log-only, cosmetic); (2) player/photonregionpings parses 2020's region=/ping= pair arrays, so 2023's region-name-keyed fields are silently dropped and pings are never recorded (degraded); (3) the three preference writers (statusvisibility, vrmovementmode, avoidjuniors) are shape-correct but discard the value — avoidjuniors GET is hardcoded false so the toggle resets every session (degraded stubs); (4) MaxPersistenceVersion, sent on every matchmake/* request, is bound nowhere in the repo (0 grep hits) — the server relies on the global admin clamp toggle instead of the client-declared cap; (5) ClientJoinData/AdditionalPlayersAutoFollow on sub-room hops and SubRoomId on code joins are ignored (spawn-target fidelity loss); (6) unknown-room paths at GoToController.cs:288/:324 emit "roomInstance": null (no null-ignore in serializer, ServiceCollectionExtensions.cs:380) while the sibling club/code handlers send an empty object — the 2020 deserializer the file's own header documents throws on non-object roomInstance; 2023's reflective reader behavior is UNKNOWN, cheap to make consistent. + +**Client-side notes.** TRANSPORT/HOST: Every route in this subsystem is issued against the matchmaking host: BNDIAONDFFF..ctor(BestHTTP.HTTPMethods verb, GJDLNNLKDIJ host, string route) with host enum value 3 at every call site (BNDIAONDFFF.txt:74 for the signature; host base URLs come from the nameserver response — PAPLNIPKAMG has a NestedType_NameServerResponse). Verb enum is BestHTTP.HTTPMethods: 0=GET, 2=POST, 3=PUT — decoded from the rdx immediate at each ctor call. Params added via BNDIAONDFFF.AFGEDDANEKP are sent as an application/x-www-form-urlencoded (or multipart when binary present) BODY on POST/PUT via HTTPFormBase.AddField (BNDIAONDFFF.txt:3032,3099), and as a ?k=v&k=v QUERY string only on body-less verbs (the \"?\"/\"&\" concat path, BNDIAONDFFF.txt:3444-3489). DorkNet's [FromForm] bindings therefore match. +CENTRAL SENDER: All matchmake/* routes except matchmake/none funnel through Matchmaking.HHJFFJEHLOB (Matchmaking.txt:11330, original name GoToHelper): POST, always adds BypassMovementModeRestriction=bool, LoginLock=string, MaxPersistenceVersion=Int32 (from GJGGDBDBCMJ — clamp-relevant, see room-version-clamp work), repeated AdditionalPlayerIds=Int32; a per-route Action closure adds JoinMode / ClientJoinData / AdditionalPlayersAutoFollow / SubRoomId. Response: JSON -> Matchmaking/PCFNLCMMGKB {errorCode:HMIBNIEBEKK enum, roomInstance:HCHDEHIGEBE} -> mapped by <>c.b__114_2 to the HMIBNIEBEKK error value the UI consumes ('Matchmaking request failed ({0}): {1}' on failure). The exact JSON key casing of PCFNLCMMGKB/HCHDEHIGEBE is reflection/attribute-driven and NOT recoverable from ISIL; the camelCase errorCode/roomInstance shape served by DorkNet (GoToController.cs:1529-1620) is verified working live against this exact build (matchmake/dorm boot, room joins, sub-room hops all functioning on the march-2023 branch), so treat it as the wire truth. +2023-vs-2020 MISMATCHES FOUND (both benign, responses ignored, but fix for fidelity): (1) player/notifydisconnect — 2023 sends PlayerId + RoomInstanceId form keys; server binds 2020's otherPlayerId so the logged id is always null (MatchPlayerController.cs:117). (2) player/photonregionpings — 2023 sends one form field per region keyed by REGION NAME (us=42&eu=90...), not the region=/ping= pair arrays the server parses (MatchPlayerController.cs:239+); 2023 pings are silently dropped. Also note verb: 2023 uses PUT here (server already accepts GET/POST/PUT). +HEARTBEAT: response IS parsed (BDDJOGCKIJO); serve the current roomInstance (never null once in a room) and appVersion as the STRING \"20230317\" or the client reports presence out-of-sync / version mismatch. avoidjuniors GET must return a bare JSON boolean primitive. +COVERAGE: all 20 routes in groups.json are real HTTP routes, all are already registered on the DorkNet server (server-routes.txt:812-847); no missing endpoints, only the two request-binding mismatches above. + +#### Endpoints + +| Verb | Route | Request | Response | +|---|---|---|---| +| POST | `matchmake/chatinvite/{0}/{1}` | route args {0},{1}=the two Int64 params (both boxed as Int64 for String.Format); the Int32 third param was not observed being sent on the wire (purpose UNKNOWN); form body: standar | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}} | +| POST | `matchmake/club/{0}` | route arg {0}=Int64 clubId (unboxed from ObscuredLong on the club DTO); form body: standard sender params only (no per-route closure) | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}}; server additionally sets a clubId on the roomInstance for the club UI | +| POST | `matchmake/code/{0}/{1}` | route args {0}=Int64 roomId, {1}=string code (custom matchmaking/room code); form body: SubRoomId=Nullable (omitted when null) + standard sender params | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}}; server uses errorCode 40 for a bad/unknown code | +| POST | `matchmake/dorm` | form-urlencoded body (POST params go through HTTPFormBase.AddField): BypassMovementModeRestriction=bool, LoginLock=string (session GUID), MaxPersistenceVersion=Int32, optional repe | {"errorCode": Int32 (0=OK, 4=RoomDoesNotExist, 26=RoomInstanceIsPrivate...), "roomInstance": {"roomInstanceId":Int64, "roomId":Int64, "subRoomId":Int64, "location":string GUID, "photonRegionId":string, "photonRoomId":str | +| POST | `matchmake/event/{0}` | route arg {0}=event id from the HPIOAGDJHDH event DTO; form body: JoinMode=Int32 + standard sender params | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}} | +| POST | `matchmake/instance/{0}` | route arg {0}=Int64 roomInstanceId; form body: standard sender params only | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}} for that specific instance | +| POST | `matchmake/invite/{0}` | route arg {0}=first Int64 param (invite id); the second Int64 (roomId) is NOT in the route and no extra form key was observed — form body: standard sender params only | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}} pointing at the inviter's instance | +| POST | `matchmake/none` | form body: LoginLock=string only | Body IGNORED — client sends via BNDIAONDFFF.FPCPAJAAHME (raw Task) and only error-status matters. 200 with any/empty body is fine (DorkNet returns a full MatchmakingResponseDto, which is harmless). | +| POST | `matchmake/player/{0}` | route arg {0}=Int32 accountId (player account ids are Int32 in this build); form body: standard sender params only | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}}; must mirror the target's live roomInstanceId/photonRoomId for the follow-join to land in the same Photon shard; errorCode 26 (RoomInstanceIsPrivate) when | +| POST | `matchmake/room/{0}` | route arg {0}=Int64 roomId; form body: JoinMode=Int32 (0 join shared instance / 1 new public / 2 new private) + standard sender params BypassMovementModeRestriction=bool, LoginLock | same MatchmakingResponse JSON as matchmake/dorm: {"errorCode":Int32, "roomInstance":{...camelCase RoomInstance...}} | +| POST | `matchmake/room/{0}/{1}` | route args {0}=Int64 roomId, {1}=Int64 subRoomId; form body: JoinMode=Int32, ClientJoinData=string (UnityEngine.JsonUtility.ToJson of RecNet.ClientJoinData — carries the optional s | MatchmakingResponse JSON {"errorCode":Int32,"roomInstance":{...}} — server must MUTATE roomInstanceId/photonRoomId/subRoomId/location/dataBlob for the target sub-room or the client's transition state machine never fires | +| GET | `player/avoidjuniors` | GET: none. PUT (same route, verb enum 3): form body avoidJuniors=bool (boxed Boolean, camelCase key verbatim). | GET: bare JSON primitive boolean (true/false) — typed send BNDIAONDFFF.FDKKOPAPDGF; returning [] or an object breaks it ('Failed to get avoid juniors status'). PUT: body ignored (KDOPJCNKOOK). | +| POST | `player/exclusivelogin` | form body: LoginLock=string, TakeOverExclusiveSession=bool | Body unused; status-driven: 409 => already-logged-in-elsewhere path ('This account is already logged in somewhere else!'), 400 => 'Unable to login (code: 4/5)'. 200 = success. | +| POST | `player/heartbeat` | form body: LoginLock=string (both variants) | Presence JSON deserialized into BDDJOGCKIJO and compared with local state. DorkNet serves (verified live with this build): {"playerId":Int32, "statusVisibility":Int32, "deviceClass":Int32, "vrMovementMode":Int32, "roomIn | +| POST | `player/login` | form body: LoginLock=string (client-generated session GUID, static Matchmaking field) | Body unused. Client branches purely on HTTP status: 409 => 'This account is already logged in somewhere else!', 400 => generic 'Unable to login'. Return 200 with empty/any body for success. | +| POST | `player/logout` | Built directly: base match-host URL + 'player/logout', BestHTTP verb enum 2 (POST), Dictionary form field LoginLock=string | Body ignored; fire-and-forget teardown. 200 empty is fine. | +| POST | `player/notifydisconnect` | form body: PlayerId=Int32 (the remote player who vanished), RoomInstanceId=Int64 (current room instance, read from HCHDEHIGEBE field +16). NOTE: keys are PascalCase 'PlayerId'/'Roo | Body ignored (plain Task via BNDIAONDFFF.KDOPJCNKOOK); 200 empty is fine. Error log 'Failed to request remote player disconnect'. | +| PUT | `player/photonregionpings` | form body: one field per measured region, key = region name string (e.g. us, eu, asia — the dictionary KEY is passed directly as the field name), value = Int32 ping ms. NOTE: this | Body ignored (KDOPJCNKOOK); 200 empty fine. Error log 'Failed to upload photon region pings'. | +| PUT | `player/statusvisibility` | form body: statusVisibility=Int32 (enum boxed as Int32; camelCase key verbatim) | Body ignored (KDOPJCNKOOK plain Task); 200 empty fine. Error log 'Failed to set player status visibility'. | +| PUT | `player/vrmovementmode` | form body: vrMovementMode=Int32 (enum boxed as Int32; camelCase key verbatim) | Body ignored; 200 empty fine. Error log 'Failed to set VR movement mode'. | + +#### Defects + +##### `PUT player/photonregionpings` — SHAPE_MISMATCH (degraded) + +Verb is fine (POST/PUT/GET all mounted, lines 239-241), but the parser reads 2020's repeated pair shape Request.Form["region"] / Request.Form["ping"] (lines 248-253). The 2023 client sends ONE form field per measured region, keyed by the region NAME with the ping as the value (us=42&eu=90...), so both arrays come back empty, pairs.Count stays 0, and presence.SetPhotonRegionPings is never called. Consequence: 2023 clients' region pings are silently dropped — the matchmaker's lowest-ping region bias never applies to them and instance creation always falls back to the configured default region. Client ignores the response, so nothing breaks visibly. + +Handler: `DorkNet.Server/Controllers/Match/MatchPlayerController.cs:239` + +**Fix.** In PhotonRegionPings, after the legacy pair parse, fall back to iterating Request.Form directly: for each (key, value) where key is not 'region'/'ping' and int.TryParse(value) succeeds, treat key as the region name and the value as the ping (same 0-2000 clamp), then feed the merged dictionary to presence.SetPhotonRegionPings. + +##### `PUT player/statusvisibility` — STUB (degraded) + +Verb and form binding are correct ([HttpPut], [FromForm(Name="statusVisibility")] — exact camelCase key the client sends), and the client ignores the response body, so no wire breakage. But the value is only echoed back and never persisted: no presence/db write exists, and Heartbeat/SetVrMovementMode hardcode StatusVisibility = 0 (lines 161, 193). The player's 'who can see my online status' choice is discarded — other players always see them as visible-to-Everyone, and the setting silently reverts. + +Handler: `DorkNet.Server/Controllers/Match/MatchPlayerController.cs:168` + +**Fix.** Persist the value per player (e.g. a field on PlayerPresenceService state or a Players column), read it back in Heartbeat/SetVrMovementMode responses, and honor it in the friend-presence read path. + +##### `PUT player/vrmovementmode` — STUB (degraded) + +Same pattern as statusvisibility: correct verb and camelCase form key 'vrMovementMode', client ignores the body, but the value is echoed and dropped — Heartbeat always reports vrMovementMode: 0 (line 163). If any server-side matchmaking logic is ever meant to gate on movement mode (the client sends BypassMovementModeRestriction on every matchmake/* call, implying the retail server matched comfort modes), it has no data to work from. + +Handler: `DorkNet.Server/Controllers/Match/MatchPlayerController.cs:184` + +**Fix.** Persist per player alongside statusVisibility and echo the stored value from Heartbeat. + +##### `GET player/avoidjuniors` — STUB (degraded) + +Shape is exactly right — GET returns a bare JSON boolean primitive (ActionResult Ok(false)) which the client's typed FDKKOPAPDGF reader requires, and PUT/POST (lines 219-223) binds the camelCase 'avoidJuniors' form key. But GET is hardcoded false and PUT discards the value (the code comment admits 'Stub for now'), so the 'avoid junior players' toggle appears to work in-session (the client caches its own write in a static) and then silently resets to Off on the next boot when the client re-reads it. Matchmaking also never actually avoids juniors since nothing is stored. + +Handler: `DorkNet.Server/Controllers/Match/MatchPlayerController.cs:211` + +**Fix.** Store the flag per player (Players table column or presence-service map), return the stored value from GetAvoidJuniors, and write it in SetAvoidJuniors. Optionally consult it in ApplyNewInstanceAsync/instance selection for actual junior-avoidance semantics. + +##### `POST matchmake/* (cross-cutting): MaxPersistenceVersion form field` — SHAPE_MISMATCH (degraded) + +The central sender adds MaxPersistenceVersion=Int32 to EVERY matchmake/* request (the client declaring the newest room-blob persistence version it can read), but a repo-wide grep for 'MaxPersistenceVersion' over *.cs returns zero hits — no handler binds it. The server instead relies on the global admin 'room version clamp' toggle (commits f5b5c14/d944a8f) to avoid handing 2023 clients blobs they can't parse. If the toggle is off, or a future mixed-version fleet connects, the server can serve a dataBlob whose persisted room version exceeds the requesting client's cap, triggering the 'update your client' room gate (room-header source; the CircuitsV2 graph-version source remains unfixable regardless). + +**Fix.** Bind [FromForm(Name = "MaxPersistenceVersion")] int? in the matchmake handlers (or read it centrally in BuildResponseAsync via Request.Form) and clamp/normalize the served dataBlob per-request against the client-declared cap, making the per-request value the primary signal and the admin toggle the fallback. + +##### `POST matchmake/room/{0} and matchmake/room/{0}/{1} (error path): roomInstance null vs empty object` — UNKNOWN (degraded) + +The unknown-room branches return RoomInstance = null (MatchmakeRoom line 288, MatchmakeSubRoom line 324, GoToRoom line 91), and with no null-ignore condition configured (ServiceCollectionExtensions.cs:380-381 sets only PropertyNamingPolicy=null) the wire carries "roomInstance": null. GoToController's own header comment (lines 20-23, from the 2020 disassembly at RVA 0x1447DD0) documents that MatchmakingResponse.Deserialize THROWS 'Matchmaking response has no RoomInstance field' when the key is missing or non-object. The 2023 client uses a reflective attribute-driven reader (per the inventory) that likely tolerates null, but this is UNVERIFIED — and the sibling error paths (GoToClub :450, GoToCode :493/:506, GoToPlayer :887) all send an empty RoomInstanceDto object instead, so the codebase is internally inconsistent. If the 2023 reader shares the strict behavior, the friendly 'room not found' toast becomes a client-side deserialization exception. + +Handler: `DorkNet.Server/Controllers/Match/GoToController.cs:288` + +**Fix.** Change the three null-returning branches to RoomInstance = new RoomInstanceDto() to match the club/code/player error paths — one-line changes at GoToController.cs:91, :288, :324; alternatively verify the 2023 PCFNLCMMGKB reader tolerates null from the decompiled tree first. + +##### `POST player/notifydisconnect` — CASING_MISMATCH (cosmetic) + +Handler binds [FromForm(Name = "otherPlayerId")] — the 2020 wire shape. The 2023 client sends PascalCase 'PlayerId' (Int32) plus 'RoomInstanceId' (Int64), so otherPlayerId binds null on every 2023 call and the log line records other=null; RoomInstanceId is dropped entirely. Functionally harmless: the handler only logs, returns bare 200, and the client ignores the response — but the endpoint's diagnostic purpose (presence GC visibility) is defeated for 2023 clients. + +Handler: `DorkNet.Server/Controllers/Match/MatchPlayerController.cs:113` + +**Fix.** In MatchPlayerController.NotifyDisconnect add [FromForm(Name = "PlayerId")] int? playerId2023 and [FromForm(Name = "RoomInstanceId")] long? roomInstanceId, coalesce otherPlayerId ?? playerId2023 for the log, and log/use the instance id (e.g. presence GC of the reported player's row when it matches). + +### Rooms, sub-rooms and room browsing + +`rooms` + +Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem against DorkNet (branch march-2023). 14 routes are fully OK — notably the boot/save-critical paths: GET rooms/{id} (bare + roomserver, BuildRoomServerDetails), subroom data commit (envelope {success,value:{Room,SubRoomDataSave}}), subroom saves (paged), room clone, recommendations/similar/bulk/browse lists, filters, verifyRole, and the matchmaking instances/inprogress/reportjoinresult trio. The defects cluster into five systemic causes. (1) roomserver/ prefix gaps: live traffic (docs/recroom-2023-room-save.md:83-93) proves every NLDBPDCNNCF request arrives prefixed 'roomserver/', but ~20 routes are registered bare-only — name, description, image, tags, warning, restrictions, cloning, automute, comments, voice_chat_encryption, modify, all bans routes, roles (all 3), interactionby/me/cheer+favorite, playerdata/me, subrooms POST/DELETE, rooms/base, rooms/hot, rooms/search, rooms/rro_ids, featuredrooms/current — each is a silent 404 for the 2023 client (settings don't save, moderation broken). (2) Wrong mutation response shape: ApplyAndReturn and the subroom mutations return slim ToWireRoom/SceneWire objects, but the client re-parses full FGCPNAACHIK details after EVERY mutation and NREs on the missing SubRooms/Roles/Tags/Stats (the repo already fixed exactly this for clone — one ApplyAndReturn change fixes ~15 routes). (3) [FromBody] vs x-www-form-urlencoded: the client sends form bodies everywhere; handlers with [FromBody] params 415 before running (mechanism empirically documented in the repo itself), and several also read the wrong keys (tags: tag/autoTag vs Tags; automute: disable; warning: warningMask; restrictions: 4 supports* bools; bans: repeated id+banMask; subroom maxplayers/modify/move/accessibility: Value/NewIndex vs maxPlayers/newRoomId/accessibility; move even has wrong semantics — reorder vs move-to-other-room). (4) Verb gaps: roominstance markprivate (POST-only, client PUTs) and roomCode (GET/POST-only, client PUTs custom code), DELETE rooms/{id} (admin-prefixed only), DELETE loadscreen (absent). (5) Stubs: playerdata/me GET hardcodes Data="CAE=" with no PUT at all (CV2 per-room player persistence dead), comments/voice_chat_encryption are acknowledged no-ops, subroom permissions matrix is never stored. Shape-only extras: magic_door lacks the {Room,RefreshesAt,RefreshIntervalMinutes} wrapper; bans GET uses BannedPlayerId/CreatedAt instead of AccountId/BannedByAccountId/BanStartTime; featuredrooms Rooms rows lack RoomName; promo_external stores Type as string and its DELETE reads the wrong keys. rooms/requiring/{mode} element type remains UNKNOWN on the client side (server returns names). + +**Client-side notes.** CROSS-CUTTING FINDINGS (rooms subsystem, client 2023-03-21): + +1. VERB ENCODING: every request is a BNDIAONDFFF request object; its ctor (0x1830036A0) takes BestHTTP.HTTPMethods in rdx (0=GET, 2=POST, 3=PUT, 4=DELETE) and the route string in r9. In the NLDBPDCNNCF thin wrappers the verb shows up as the 'Move r8, N' immediately before the shared send helper. All verbs above were read off these constants, not guessed. + +2. BODY ENCODING: mutating endpoints add fields via BNDIAONDFFF.AFGEDDANEKP(key, boxedValue) = x-www-form-urlencoded (verified against live traffic for clone: 'name=...' form body, docs/recroom-2023-room-save.md). The ONE JSON-body rooms endpoint is POST rooms/{id}/subrooms/{id}/data, which sets a raw serialized body via BNDIAONDFFF.FJLLPHFOOJJ and adds an X-On-Behalf-Of header. List-valued form fields repeat the key (id=..&id=.., tag=..&tag=..). + +3. RESPONSE KEY CASING: every RecNet.Runtime DTO reader (files with 'DTO JFCMHHFNDFE' methods) matches each key against three literals — PascalCase, camelCase, all-lowercase — so PascalCase server output is always safe for these DTOs. The full DTO→reader→keyset map is saved at the audit working set (291 DTOs). + +4. STRICTNESS: every room mutation (name/description/accessibility/roles/subroom ops/clone/...) expects the FULL FGCPNAACHIK room-details object back, and the client dispose-walk NREs on missing nested objects (Stats, SubRooms, Roles, Tags) — a partial response makes the UI show failure even though the server persisted. RankingContext is parsed by LHGPEEIOPHN as an opaque STRING (String getter/setter only) — sending a JSON object for it broke every room-details parse (see DorkNet.Server.Tests/RoomSave2023Tests.cs comment re befd590 revert). + +5. HOST/PREFIX: NLDBPDCNNCF targets the rooms host; the same routes are also issued with a 'roomserver/' path prefix depending on service config (verified for clone in docs/recroom-2023-room-save.md). DorkNet serves every bare route in this subsystem (diffed against the server's reflected route table — zero bare gaps), but these roomserver/ twins are NOT registered and are potential silent-404s if the client picks the prefixed base: rooms/base, rooms/hot, rooms/search, rooms/rro_ids, rooms/{id}/{name,description,image,tags,warning,restrictions,cloning,comments,voice_chat_encryption,automute,modify}, rooms/{id}/roles[...], rooms/{id}/bans[...], rooms/{id}/interactionby/me/{cheer,favorite}, rooms/{id}/playerdata/me. + +6. MATCHMAKING-SERVICE ROUTES: room/{0}/instances, roominstance/{0}/* and rooms/requiring/ are issued by RecNet.Matchmaking with service id 3 (r8=3 in the BNDIAONDFFF ctor) — the matchmaking host, not the rooms host. RoomInstance payloads elsewhere in matchmaking deserialize via HCHDEHIGEBE (reader NHGOFGABJIN.txt line 2590: ClubId, EncryptVoiceChat, EventId, IsFull, IsInProgress, IsPrivate, Location, MaxCapacity, Name, PhotonRegionId, PhotonRoomId, RoomCode, RoomId, RoomInstanceId, RoomInstanceType, SubRoomId). + +7. NON-ROUTES: 7 of the 82 literals are not HTTP routes — hot_rooms/…, magic_door/{0}, my_visited_rooms/…, roominteraction/{0}, roomsbycreators/…, search_rooms/…, visited_rooms/… are cache keys minted by the string-returning methods of IBEOONPEELF_NestedType_GGDPFFDEDKN.txt, and 'room/' is a share-code deeplink prefix (RoomCode_NestedType_IDKJONDCJKN.txt). + +8. UNKNOWNS: rooms/requiring/{mode} response element type could not be resolved from the ISIL metadata token (stored internally as a restricted-rooms list; server already implements rooms/requiring/{restriction}). The exact wire type of BNLPBMJJOMM.Value (subroom permissions) is a variant read through the shared value reader — keys are certain, scalar type is not. + +#### Endpoints + +| Verb | Route | Request | Response | +|---|---|---|---| +| GET | `api/rooms/v1/filters` | none | AKCLLEJNFFD object: {"PinnedFilters": [String], "PopularFilters": [String], "TrendingFilters": [String]} — reader iterates exactly these 3 List fields; accepts PascalCase/camelCase/lowercase variants of each key | +| POST | `api/rooms/v1/verifyRole` | x-www-form-urlencoded fields: roomId (Int64, current room from Matchmaking ObscuredLong), role (Int32 enum), context (String). Keys are camelCase. | none parsed — client ignores the body; return 200 | +| POST | `api/rooms/v2/report` | x-www-form-urlencoded fields (PascalCase!): RoomId (Int64), RoomKeyId (Nullable), Details (String), ReportCategory (Int32 enum FPIBGPIAOBI/BDOGOIGCKMK) | none parsed — logged as 'Failed to report room' on error; return 200 | +| GET | `featuredrooms/current` | none | JBHJKCCFCIO: {"FeaturedRoomGroupId": Int64, "Name": String, "Rooms": [COGJIOGPNGD {"ImageName": String, "RoomId": Int64, "RoomName": String}]} (casing-tolerant) | +| GET | `room/{0}/instances` | none | JSON array: [PNDCMIMEJLD {"RoomInstanceId": Int64, "RoomId": Int64, "SubRoomId": Int64, "PlayerIds": [Int32], "IsFull": Boolean, "CreatedAt": DateTime string}] (casing-tolerant) | +| PUT | `roominstance/{0}/inprogress` | x-www-form-urlencoded field: inProgress (Boolean) | none parsed ('Failed to modify current room's game-in-progress state' on error); return 200 | +| PUT | `roominstance/{0}/markprivate` | none (empty body) | none parsed ('Failed to mark room instance {0} as private'); return 200 | +| POST | `roominstance/{0}/reportjoinresult` | x-www-form-urlencoded field: result (Int32 enum MEIJCIADDHJ) | none parsed ('Failed to report room join result'); return 200 | +| PUT | `roominstance/{0}/roomCode` | x-www-form-urlencoded fields: roomCode (String), forceChange (Boolean) | String (the accepted custom room code) — FGLDKEJLAKB promise | +| GET | `rooms/base` | none | [NEMINAEBALC] — room summary, keys (PascalCase, casing-tolerant): RoomId Int64, Name String, Description String, ImageName String, CreatorAccountId Int64, CreatedAt DateTime, Accessibility Int32 enum, MaxPlayers Int32, M | +| GET | `rooms/bulk` | query params: repeated id (Int64) for the id overload; repeated name (String) for the name overload | [NEMINAEBALC] (same keyset as rooms/base) | +| GET | `rooms/cheeredby/me` | none | [NEMINAEBALC] | +| GET | `rooms/contestwinners` | none | [NEMINAEBALC] | +| GET | `rooms/createdby/me` | none | [NEMINAEBALC] | +| GET | `rooms/createdby/{0}` | none (accountId in path) | [NEMINAEBALC] | +| GET | `rooms/curated_playlists` | none | [Int64] — bare array of curated playlist ids | +| GET | `rooms/favoritedby/me` | none | [NEMINAEBALC] | +| GET | `rooms/fromcreators` | query params: repeated id (Int32 creator account ids), skip (Int32), take (Int32) | [NEMINAEBALC] | +| GET | `rooms/hot` | query params: repeated tag (String), skip (Int32), take (Int32) | FFCPFGBNLHN paged object: {"Results": [NEMINAEBALC], "TotalResults": Int32} | +| GET | `rooms/magic_door` | query param: partySize (Int32) | GAPJAGDNFEO: {"Room": NEMINAEBALC, "RefreshesAt": DateTime string, "RefreshIntervalMinutes": Int32} | +| GET | `rooms/moderatedby/me` | none | [NEMINAEBALC] | +| GET | `rooms/ownedby/me` | none | [NEMINAEBALC] | +| GET | `rooms/ownedby/{0}` | none | [NEMINAEBALC] | +| GET | `rooms/recommendations` | none | [OPGDCAELKMJ {"SeedRoom": NEMINAEBALC, "Rooms": [NEMINAEBALC]}] — list of seed-room groups, NOT a flat room list | +| GET | `rooms/requiring/` | none — mode enum name lowercased and concatenated onto the path: rooms/requiring/{mode} | Restricted-rooms list stored internally ('Failed to get restricted rooms list: ' on error). Element type not resolvable from ISIL metadata token — UNKNOWN (DorkNet serves rooms/requiring/{restriction}); likely an array o | +| GET | `rooms/rro_ids` | none | [Int64] — bare array of Rec Room Original room ids | +| GET | `rooms/search` | query params: query (String), skip (Int32), take (Int32) | FFCPFGBNLHN: {"Results": [NEMINAEBALC], "TotalResults": Int32} | +| GET | `rooms/topcreators` | none | [NEMINAEBALC] | +| GET | `rooms/visitedby/me` | query params: skip (Int32), take (Int32) | [NEMINAEBALC] | +| GET | `rooms/visitedby/{0}` | query params: skip (Int32), take (Int32) | [NEMINAEBALC] | +| GET (2 overloads) + DELETE | `rooms/{0}` | GET summary: none. GET details: query params include (Byte bitmask), unityAssetTarget (String), unityAssetVersion (Int32) from closure CAEBODLFAMA. DELETE: none. | GET summary → NEMINAEBALC. GET details → FGCPNAACHIK = NEMINAEBALC keys PLUS: "DataBlob" String, "DataBlobHash" String, "LoadScreens": [{"ImageName","Title","Subtitle" all String}], "PromoImages": [String], "PromoExterna | +| PUT | `rooms/{0}/accessibility` | x-www-form-urlencoded field: accessibility (Int32 enum JHAOFLCJNAL) | FGCPNAACHIK full room details (see rooms/{0}) — client re-parses the whole object; missing keys NRE | +| PUT | `rooms/{0}/allow_new_users` | form field: allowNewUsers (Boolean) | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/automute` | form field: disable (Boolean) — NOTE key is 'disable', maps to DisableMicAutoMute | FGCPNAACHIK full room details | +| GET + POST | `rooms/{0}/bans` | GET: none. POST 'ban players': form fields id (repeated Int32 account ids) + banMask (Int32 enum DCDPJPHBHOA) | GET → [IBHAKOOKEEE {"AccountId": Int32, "BannedByAccountId": Int32, "BanStartTime": DateTime string}]. POST → none parsed. | +| POST | `rooms/{0}/bans/import` | form field: sourceRoomId (Int64) | none parsed; return 200 | +| DELETE | `rooms/{0}/bans/{1}` | form field: banMask (Int32 enum) | none parsed; return 200 | +| POST | `rooms/{0}/clone` | x-www-form-urlencoded field: name (String) — new room name | FGCPNAACHIK FULL room details of the clone (not a status wrapper); client 404→empty-body surfaces as message-less 'Failed to copy room' | +| PUT | `rooms/{0}/cloning` | form field: cloningAllowed (Boolean) | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/comments` | form field: disable (Boolean) — key is 'disable', maps to DisableRoomComments | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/creator` | form field: accountId (Int32) | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/description` | form field: description (String) | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/image` | form field: imageName (String — pre-uploaded image blob name) | FGCPNAACHIK full room details | +| GET | `rooms/{0}/interactionby/me` | none | CNINIABILDI: {"Cheered": Boolean, "Favorited": Boolean, "LastVisitedAt": DateTime string} (casing-tolerant) | +| PUT + DELETE | `rooms/{0}/interactionby/me/cheer` | none (empty body) | none parsed; return 200 | +| PUT + DELETE | `rooms/{0}/interactionby/me/favorite` | none (empty body) | none parsed; return 200 | +| PUT + DELETE | `rooms/{0}/loadscreen` | PUT form fields: imageName (String), title (String), subtitle (String). DELETE form field: imageName (String). | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/max_player_calculation_mode` | form field: maxPlayerCalculationMode (Int32 enum DDIFIKACNBN) | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/min_level` | form field: minLevel (Int32) | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/modify` | form fields (camelCase): name, description (String), accessibility (Int32 enum), supportsJuniors, supportsScreens, supportsTeleportVR, supportsWalkVR, cloningAllowed, disableMicAut | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/name` | form field: name (String) | FGCPNAACHIK full room details | +| GET + PUT | `rooms/{0}/playerdata/me` | GET: none. PUT: form field data (String — opaque per-room player data blob). | GET → ILKMFMCOPPO: {"Data": String}. PUT → none parsed. | +| PUT + DELETE | `rooms/{0}/promo_external` | form fields: type (Int32 enum OJGHPAELGBO), reference (String) — same keys on both verbs | FGCPNAACHIK full room details | +| PUT + DELETE | `rooms/{0}/promo_images` | form field: imageName (String) on both verbs | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/restrictions` | form fields: supportsJuniors, supportsScreens, supportsTeleportVR, supportsWalkVR (Booleans) | FGCPNAACHIK full room details | +| GET | `rooms/{0}/roles` | none | [EFHPLDPNGIM {"AccountId": Int32, "Role": Int32 enum, "InvitedRole": Int32 enum, "LastChangedByAccountId": Int32}] (casing-tolerant) | +| GET + PUT | `rooms/{0}/roles/{1}` | GET: none. PUT: form field role (Int32 enum OMMBGJMJJPN). | GET → EFHPLDPNGIM single object. PUT → FGCPNAACHIK full room details. | +| PUT | `rooms/{0}/roles/{1}/invite` | form field: role (Int32 enum) | FGCPNAACHIK full room details | +| GET | `rooms/{0}/similar` | none | single OPGDCAELKMJ: {"SeedRoom": NEMINAEBALC, "Rooms": [NEMINAEBALC]} | +| POST | `rooms/{0}/subrooms` | form field: name (String) — new subroom name | FGCPNAACHIK full room details (with the new SubRooms entry) | +| DELETE | `rooms/{0}/subrooms/{1}` | none | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/subrooms/{1}/accessibility` | form field: accessibility (Int32 enum) | FGCPNAACHIK full room details | +| POST | `rooms/{0}/subrooms/{1}/clone` | none (empty body) | FGCPNAACHIK full room details | +| POST | `rooms/{0}/subrooms/{1}/data` | JSON body (raw body via BNDIAONDFFF.FJLLPHFOOJJ, not form): {"UnityAssetId": null\|String, "RoomData": {"Filename": String, "Hash": null, "OwnershipProof": null}, "SubRoomData": {" | legacy {success,value,error} envelope; value = NEOPBOMGIOG: {"Room": FGCPNAACHIK (FULL details — must include DataBlob, DataBlobHash, MaxPlayers, ToxmodEnabled, SubRooms/Roles/Tags/Stats or the client NREs in the respons | +| PUT | `rooms/{0}/subrooms/{1}/maxplayers` | form field: maxPlayers (Int32) | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/subrooms/{1}/modify` | form fields: name (String), accessibility (Int32 enum), maxPlayers (Int32) | FGCPNAACHIK full room details | +| POST | `rooms/{0}/subrooms/{1}/move` | form field: newRoomId (Nullable — target room) | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/subrooms/{1}/name` | form field: name (String) | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/subrooms/{1}/permissions` | serialized list of BNLPBMJJOMM permission entries; per-entry keys (from its serializer): Role (Int32), Type (Int32), Permission (Int32), Override (Boolean), Value (variant) | FGCPNAACHIK full room details | +| POST | `rooms/{0}/subrooms/{1}/publish_save` | form field: subRoomDataSaveId (Int64) | FGCPNAACHIK full room details | +| GET | `rooms/{0}/subrooms/{1}/saves` | query params: skip (Int32), take (Int32) | paged object GJDIKIMLCHA: {"Results": [JKIFFPPAJNK {"SubRoomDataSaveId" Int64, "SubRoomId" Int64, "UnityAssetId" String, "DataBlob" String, "DataBlobHash" String, "Description" String, "SavedByAccountId" Int64, "SavedOnP | +| PUT | `rooms/{0}/tags` | form fields: tag (repeated String), autoTag (repeated String) | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/voice_chat_encryption` | form field: encryptVoiceChat (Boolean) | FGCPNAACHIK full room details | +| PUT | `rooms/{0}/warning` | form fields: warningMask (Int32 enum NGPHEBMBPIC), customWarning (String) | FGCPNAACHIK full room details | + +#### Not HTTP routes + +These literals look like paths but are cache keys, deeplinks or MIME types: + +- `hot_rooms/{0}&skip={1}&take={2}` — Cache-key factory for the hot-rooms client cache +- `magic_door/{0}` — Cache-key factory +- `my_visited_rooms/skip={0}&take={1}` — Cache-key factory +- `room/` — Room share-code / deeplink construction +- `roominteraction/{0}` — Client cache invalidation after cheer/favorite mutations +- `roomsbycreators/{0}&skip={1}&take={2}` — Cache-key factory +- `search_rooms/{0}&skip={1}&take={2}` — Cache-key factory +- `visited_rooms/account_id={0}&skip={1}&take={2}` — Cache-key factory + +#### Defects + +##### `PUT roominstance/{0}/markprivate` — VERB_MISMATCH (breaks-gameplay) + +Server registers POST only ([HttpPost] at :326). The 2023 client sends PUT (Matchmaking.txt:15483/15774, ctor verb constant 3=PUT) with an empty body → ASP.NET returns 405 Method Not Allowed, the client logs 'Failed to mark room instance {0} as private', and the host cannot close a public instance to private. + +Handler: `DorkNet.Server/Controllers/Match/MatchController.cs:326` + +**Fix.** Add [HttpPut("/roominstance/{instanceId:long}/markprivate")] to MarkPrivate (keep POST for the 2020 client). + +##### `PUT roominstance/{0}/roomCode` — VERB_MISMATCH (breaks-gameplay) + +Server registers GET+POST only; the 2023 client sets a custom room code via PUT with form fields roomCode + forceChange and expects the accepted code back as a raw JSON string → PUT gets 405 and the host cannot set/change a custom room code. Secondary: the existing handler ignores any submitted roomCode and always returns RoomCodeService.Generate(instanceId), so even after adding PUT the requested custom code would be discarded (the string response shape itself is correct: Content(JsonSerializer.Serialize(code))). + +Handler: `DorkNet.Server/Controllers/Match/MatchController.cs:363` + +**Fix.** Add [HttpPut] to the roomcode route; when the request carries a roomCode form field, validate/uniqueness-check it, persist it against the instance, and return it as a JSON string (honor forceChange). + +##### `GET rooms/base` — MISSING (breaks-gameplay) + +Bare rooms/base exists, but there is NO roomserver/rooms/base registration (routes list has roomserver/rooms — the by-name lookup — which does not match /roomserver/rooms/base). This route is issued by NLDBPDCNNCF whose requests all carry the roomserver/ prefix in this deployment (docs:83-93, RoomsController.cs:439-443), so the 2023 base/AG-rooms list request 404s. Secondary: rows are slim ToWireRoom objects missing NEMINAEBALC keys MaxPlayers/MinLevel/MaxPlayerCalculationMode/PersistenceVersion/ToxmodEnabled — tolerant reader defaults these, cosmetic only. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:307` + +**Fix.** Add [HttpGet("roomserver/rooms/base")] to BaseRooms; optionally emit rows via BuildRoomServerListAsync for key parity with the other list endpoints. + +##### `GET + DELETE rooms/{0}` — MISSING (breaks-gameplay) + +GET is fine: bare (:390) and roomserver (:1445) both return BuildRoomServerDetails, and the include/unityAssetTarget/unityAssetVersion query params are numeric on the wire (closure CAEBODLFAMA boxes Int32/Nullable/Nullable) so the int? bindings work. DELETE is MISSING on the game path entirely — the only [HttpDelete("rooms/{id:long}")] lives in AdminController under [Route("api/admin/v1")] (AdminController.cs:25,442), so the client's owner delete-room (NLDBPDCNNCF.FPDBNHOHIAO, verb 4) gets 405 on rooms/{id} and 404 on roomserver/rooms/{id} — owners cannot delete rooms. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:390 (GET); none (DELETE)` + +**Fix.** Add DELETE rooms/{roomId:long} + roomserver twin on the rooms controller: owner/co-owner gate, soft-archive (State=1) like AdminController.DeleteRoom, return 200 (client parses no body). + +##### `PUT rooms/{0}/automute` — MISSING (breaks-gameplay) + +No roomserver twin → 404 under the 2023 client's prefix. Additionally the handler binds [FromBody] BareBoolRequest + [FromForm(Name="DisableMicAutoMute")], but the client's form key is 'disable' — so even via the bare path the value is never read (no-op), and the [FromBody] parameter makes a form POST 415 (repo-documented mechanism). Response is ToWireRoom. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:739` + +**Fix.** Add roomserver twins; drop [FromBody], read form key disable (plus DisableMicAutoMute alias) via ReadBareBoolAsync; return full details. + +##### `GET + POST rooms/{0}/bans` — SHAPE_MISMATCH (breaks-gameplay) + +No roomserver twins for either verb (404 under prefix). GET response keys are Id/RoomId/BannedPlayerId/BannedByPlayerId/BanType/Until/Reason/CreatedAt (:245-255) but the client's IBHAKOOKEEE reader wants AccountId/BannedByAccountId/BanStartTime — zero overlap, so the banned-players screen shows default-zero entries. POST binds [FromBody] BareBanRequest{PlayerId,BanType} while the client sends form repeated 'id' + 'banMask' → 415 (form) and wrong keys anyway — in-room bans never persist. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:1167` + +**Fix.** Add roomserver twins; GET: emit {AccountId=(int)BannedPlayerId, BannedByAccountId=(int)BannedByPlayerId, BanStartTime=CreatedAt}; POST: read form repeated id + banMask, create a ban row per id. + +##### `PUT rooms/{0}/cloning` — MISSING (breaks-gameplay) + +No roomserver twin → 404 under the 2023 prefix. Bare handler also mixes [FromBody]+[FromForm] (form POST likely 415 per the repo-documented [FromBody] behavior; the form Name="CloningAllowed" would match the client's 'cloningAllowed' case-insensitively if binding got that far) and returns ToWireRoom. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:729` + +**Fix.** Add roomserver twins; drop [FromBody] and use ReadBareBoolAsync("cloningAllowed",...); return full details. + +##### `PUT rooms/{0}/description` — MISSING (breaks-gameplay) + +No roomserver twin → the 2023 edit-description PUT 404s. Bare handler also carries a [FromBody] parameter (form POST at risk of 415) and returns ToWireRoom. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:459` + +**Fix.** Add roomserver twins; replace [FromBody]+[FromForm] with ReadStringValueAsync("description",...); return full details. + +##### `PUT rooms/{0}/image` — MISSING (breaks-gameplay) + +No roomserver twin → set-thumbnail 404s. Same [FromBody] 415 risk and ToWireRoom response as description. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:466` + +**Fix.** Add roomserver twins; ReadStringValueAsync("imageName",...); full-details response. + +##### `PUT + DELETE rooms/{0}/loadscreen` — MISSING (breaks-gameplay) + +DELETE is not registered at all (bare or roomserver) — 'remove room loading screen' gets 405 → cannot remove load screens. PUT exists with twins and form support, but ReadJsonElementAsync serializes the whole form as ONE object and REPLACES LoadScreensJson (:845-847) instead of appending, so a room can never hold more than one load screen. Response is ToWireRoom. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:834` + +**Fix.** Register DELETE (bare + roomserver) reading form imageName and removing the matching entry; change PUT to append {ImageName,Title,Subtitle} to the existing list; return full details. + +##### `PUT rooms/{0}/modify` — MISSING (breaks-gameplay) + +No roomserver twin → the room-settings bulk save 404s. Bare BareModify also binds [FromBody] ModifyRoomRequest → 415 for the client's x-www-form-urlencoded body; the DTO is missing the client's fields supportsJuniors/cloningAllowed/disableRoomComments/encryptVoiceChat/accessibility-as-form; response is {Result, Room: ToWireRoom} — not the FGCPNAACHIK object. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:950` + +**Fix.** Add roomserver twins; parse the 11 form keys (name/description/accessibility/supportsJuniors/supportsScreens/supportsTeleportVR/supportsWalkVR/cloningAllowed/disableMicAutoMute/disableRoomComments/encryptVoiceChat) directly from Request.Form; return BuildRoomServerDetails. + +##### `PUT rooms/{0}/name` — MISSING (breaks-gameplay) + +No roomserver/rooms/{id}/name twin (the SUBROOM rename got twins at :2658-2659, the room rename did not) → 2023 rename 404s. Bare handler also mixes [FromForm]+[FromBody RenameBody] (form PUT at risk of the repo-documented 415) and returns a minimal {Result,RoomId,Name,...} object instead of full FGCPNAACHIK details (no Stats/SubRooms/Roles/Tags) → client parse fails even on success. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2590` + +**Fix.** Add roomserver twins; drop the [FromBody] parameter (read form name); return BuildRoomServerDetailsWithRolesAsync. + +##### `GET + PUT rooms/{0}/playerdata/me` — STUB (breaks-gameplay) + +PUT is MISSING everywhere (only GET registered) → saving per-room CV2 player data gets 405 and nothing persists. GET is a STUB: Data is hardcoded to "CAE=" (:2331) for every player/room, so persistent per-room player state always loads the same canned blob. Also GET has no roomserver twin → 404 under the 2023 prefix. The client contract is GET → {Data: string}, PUT form field data. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2307` + +**Fix.** Add a PlayerRoomData table keyed (playerId, roomId); PUT (bare + roomserver) stores form 'data'; GET returns the stored blob (default empty/"CAE="); add roomserver twin for GET. + +##### `PUT rooms/{0}/restrictions` — MISSING (breaks-gameplay) + +No roomserver twin → 404 under prefix. Bare handler reads a single bool (form AllowsJuniors / body Value) but the client sends FOUR booleans: supportsJuniors, supportsScreens, supportsTeleportVR, supportsWalkVR — three of four settings are never read, and the [FromBody] parameter risks 415 on the form body. Response is ToWireRoom. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:752` + +**Fix.** Add roomserver twins; read all four form keys via ReadBareBoolAsync each and map to SupportsScreens/SupportsTeleportVR/SupportsWalkVR/AllowsJuniors; return full details. + +##### `POST rooms/{0}/subrooms` — MISSING (breaks-gameplay) + +Only the bare POST exists (the GET at :532 has a twin; the POST does not) → 'add new subroom' 404s under the 2023 prefix. The bare handler also binds [FromBody] CreateSubRoomRequest → 415 for the client's form 'name', and returns SceneWire instead of the full FGCPNAACHIK details the client re-parses. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3298` + +**Fix.** Add [HttpPost("roomserver/rooms/{roomId:long}/subrooms")]; read form name; return BuildRoomServerDetailsWithRolesAsync. + +##### `DELETE rooms/{0}/subrooms/{1}` — MISSING (breaks-gameplay) + +Bare DELETE only (the roomserver/rooms/{id}/subrooms/{sub} registration at :554-555 is GET) → delete-subroom 404s under the prefix. Response {Result:0} is also not the expected full details. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3330` + +**Fix.** Add roomserver DELETE twin; return full details. + +##### `PUT rooms/{0}/subrooms/{1}/accessibility` — SHAPE_MISMATCH (breaks-gameplay) + +Twins exist, but the handler binds [FromBody] SubRoomBoolRequest{Value:bool} while the client sends form 'accessibility' as an Int32 enum (JHAOFLCJNAL) → form body gets 415 (repo-documented [FromBody] behavior) and the key/type don't match anyway; response is SceneWire, not full details. Subroom accessibility can't be changed. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3383` + +**Fix.** Drop [FromBody]; read form int accessibility (ReadBareIntAsync pattern) and map to CanMatchmakeInto/scene accessibility; return full details. + +##### `PUT rooms/{0}/subrooms/{1}/maxplayers` — SHAPE_MISMATCH (breaks-gameplay) + +Twins exist (added after the 404 was diagnosed), but the handler binds [FromBody] SubRoomIntRequest{Value} while the client sends form 'maxPlayers' → the form PUT 415s and even a JSON body would need key Value, so the max-players slider still can't persist. Response is SceneWire, not full details. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3375` + +**Fix.** Drop [FromBody]; read form maxPlayers (int); return full details. + +##### `PUT rooms/{0}/subrooms/{1}/modify` — SHAPE_MISMATCH (breaks-gameplay) + +Twins exist, but [FromBody] SubRoomModifyRequest → 415 on the client's form body (keys name/accessibility/maxPlayers); 'accessibility' isn't even a property of the DTO. Response is SceneWire. Subroom settings bulk-save fails. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3525` + +**Fix.** Drop [FromBody]; read the three form keys; map accessibility; return full details. + +##### `PUT rooms/{0}/tags` — MISSING (breaks-gameplay) + +No roomserver twin → edit-tags 404s under the 2023 prefix. Bare handler reads form key 'Tags' as CSV, but the client sends REPEATED form keys 'tag' and 'autoTag' → never read (no-op) and the [FromBody] parameter risks 415. Response ToWireRoom. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:473` + +**Fix.** Add roomserver twins; read Request.Form["tag"] + Request.Form["autoTag"] as lists, join into TagsCsv; return full details. + +##### `PUT rooms/{0}/warning` — MISSING (breaks-gameplay) + +No roomserver twin → 404 under the 2023 prefix. Bare handler is [FromBody] BareWarningRequest{RoomWarningMask,CustomRoomWarning} → 415 on the client's form body, whose keys are warningMask/customWarning anyway. Response ToWireRoom. Content warnings can't be saved. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:762` + +**Fix.** Add roomserver twins; read form warningMask (int) + customWarning (string); return full details. + +##### `POST api/rooms/v2/report` — SHAPE_MISMATCH (degraded) + +Handler is [FromBody] RoomReportRequest{RoomId,Category,Message} (JSON only). The client sends x-www-form-urlencoded with PascalCase keys RoomId/RoomKeyId/Details/ReportCategory. A [FromBody] handler returns 415 for a form POST before the handler runs (mechanism empirically documented in this repo at RoomsModerationController.cs:963-968 and RoomSave2023Tests.cs:246-248). Even with JSON the field names differ (Category vs ReportCategory, Message vs Details). Result: room reports are never persisted; client logs 'Failed to report room' but UI is otherwise unaffected. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:267` + +**Fix.** In Report(), drop [FromBody]; read Request.Form keys RoomId/RoomKeyId/Details/ReportCategory (case-insensitive) with a JSON fallback, map ReportCategory→Category and Details→Message. + +##### `GET featuredrooms/current` — SHAPE_MISMATCH (degraded) + +Two issues: (1) Rooms entries are RoomService.ToWireRoom objects (RoomId/Name/ImageName...) but the 2023 COGJIOGPNGD reader reads ImageName/RoomId/RoomName — there is no 'RoomName' key, so every featured tile's room name deserializes to null (reader is casing-tolerant and defaults missing keys, so no crash). (2) No roomserver/featuredrooms/current twin is registered; this route is called from NLDBPDCNNCF, the client whose every other request arrives with the roomserver/ prefix (docs/recroom-2023-room-save.md:83-93), so the 2023 call likely 404s outright. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2535` + +**Fix.** Add RoomName (= room.Name) to each Rooms entry in FeaturedRoomsCurrent, and register [HttpGet("roomserver/featuredrooms/current")] on the same action. + +##### `GET rooms/hot` — MISSING (degraded) + +Paged {Results,TotalResults} wrapper is correct, but no roomserver/rooms/hot twin is registered → the NLDBPDCNNCF hot-rooms call 404s under the roomserver/ prefix (the play-menu 'hot' UI separately uses rooms/hot_rooms/{...} which IS served, so impact is limited to whatever surface uses NLDBPDCNNCF.FANOAMOPHBE). Secondary: [FromQuery] string? tag binds only the FIRST repeated tag value, and rows are slim ToWireRoom. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:56` + +**Fix.** Add [HttpGet("roomserver/rooms/hot")]; read Request.Query["tag"] as a list. + +##### `GET rooms/magic_door` — SHAPE_MISMATCH (degraded) + +Server returns the bare room-details object. The 2023 GAPJAGDNFEO reader expects a wrapper {Room: , RefreshesAt: DateTime, RefreshIntervalMinutes: Int32}; with no 'Room' key the parsed DTO has Room=null and MagicDoorManager gets no destination — Magic Door dead/blank. partySize query param is ignored (acceptable). + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:154` + +**Fix.** Wrap the response: Ok(new { Room = wireRoom, RefreshesAt = , RefreshIntervalMinutes = N }). + +##### `GET rooms/requiring/` — UNKNOWN (degraded) + +Route + roomserver twin exist and accept the lowercased mode segment. Server returns a bare array of room NAMES (strings). The client's element type could not be resolved from ISIL metadata (restricted-rooms list; likely room ids). If the client expects Int64 ids, string names would parse to defaults or fail silently — cannot confirm either way. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2351` + +**Fix.** Resolve the element type from the client (trace the list's consumer in Matchmaking) before changing; if ids are expected, Select(r => r.Id). + +##### `GET rooms/rro_ids` — MISSING (degraded) + +Bare route returns [Int64] (correct shape) but no roomserver/rooms/rro_ids twin exists → the NLDBPDCNNCF call 404s under the roomserver/ prefix and RRO badging/gating falls back to nothing. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2511` + +**Fix.** Add [HttpGet("roomserver/rooms/rro_ids")] to AgRoomIds. + +##### `GET rooms/search` — MISSING (degraded) + +Paged {Results,TotalResults} shape and query/skip/take params are correct, but no roomserver/rooms/search twin → 404 under the roomserver/ prefix (play-menu search separately uses rooms/search_rooms/{...} which IS served and test-covered, so impact limited to the NLDBPDCNNCF.MOBJJDBNBMF surface). Rows are slim ToWireRoom (cosmetic). + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:267` + +**Fix.** Add [HttpGet("roomserver/rooms/search")]. + +##### `PUT rooms/{0}/accessibility` — SHAPE_MISMATCH (degraded) + +Routing/verbs/body OK (roomserver twins registered, [Consumes] form, reads 'accessibility'). Defect: ApplyAndReturn responds with RoomService.ToWireRoom (RoomsModerationController.cs:1210) — the slim room object with NO SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob/MinLevel/MaxPlayers. The 2023 client re-parses the FULL FGCPNAACHIK details after every mutation and its dispose-walk NREs on the missing nested lists (same failure the repo already documented for clone at RoomsModerationController.cs:1117-1125), so the toggle persists but the client reports failure. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:485` + +**Fix.** Change ApplyAndReturn to return RoomsController.BuildRoomServerDetails (load scenes+roles like BuildRoomServerDetailsWithRolesAsync) instead of ToWireRoom — this one fix repairs the response of every bare-path room mutation. + +##### `PUT rooms/{0}/allow_new_users` — SHAPE_MISMATCH (degraded) + +Twins + form key allowNewUsers OK; response is ToWireRoom (same ApplyAndReturn defect as accessibility). + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:789` + +**Fix.** Same ApplyAndReturn fix. + +##### `POST rooms/{0}/bans/import` — SHAPE_MISMATCH (degraded) + +No roomserver twin; handler binds [FromBody] ImportRoomBansRequest (expects a Bans list) while the client sends form sourceRoomId → 415, and even as JSON the contract differs (client names a source ROOM to copy bans from; handler expects an explicit ban list). Import-ban-list never works. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:1195` + +**Fix.** Add roomserver twin; read form sourceRoomId, copy that room's RoomBans rows into the target (owner-gated on both rooms). + +##### `DELETE rooms/{0}/bans/{1}` — MISSING (degraded) + +Bare DELETE works (banMask form field is ignored, which is fine — client parses no response), but no roomserver twin → unban 404s under the 2023 prefix. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:1181` + +**Fix.** Add [HttpDelete("roomserver/rooms/{roomId:long}/bans/{playerId:long}")]. + +##### `PUT rooms/{0}/comments` — STUB (degraded) + +No roomserver twin (404 under prefix), and the bare handler BareAck is an acknowledged no-op — DisableRoomComments is never persisted and the wire always reports DisableRoomComments=false, so the toggle never sticks across loads. Client's form key 'disable' is never read. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:940` + +**Fix.** Add a DisableRoomComments column on RoomEntity, read form 'disable', persist, surface in both detail builders; add roomserver twins; return full details. + +##### `PUT rooms/{0}/creator` — SHAPE_MISMATCH (degraded) + +Twins + form key accountId handled (ReadBareLongAsync includes 'accountId'); response is ToWireRoom (same ApplyAndReturn defect). + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:771` + +**Fix.** Same ApplyAndReturn fix. + +##### `PUT + DELETE rooms/{0}/interactionby/me/cheer` — MISSING (degraded) + +Bare PUT/POST/DELETE all exist and the client parses no body, but there are NO roomserver twins → cheer/uncheer 404 under the 2023 prefix and the button silently fails. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2761` + +**Fix.** Add roomserver/ twins for PUT+DELETE (and favorite below). + +##### `PUT + DELETE rooms/{0}/interactionby/me/favorite` — MISSING (degraded) + +Same as cheer: bare verbs fine, no roomserver twins → 404 under prefix. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2780` + +**Fix.** Add roomserver twins. + +##### `PUT rooms/{0}/max_player_calculation_mode` — SHAPE_MISMATCH (degraded) + +Twins + form key maxPlayerCalculationMode OK; response is ToWireRoom (ApplyAndReturn defect). + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:817` + +**Fix.** Same ApplyAndReturn fix. + +##### `PUT rooms/{0}/min_level` — SHAPE_MISMATCH (degraded) + +Twins + form key minLevel OK; response is ToWireRoom (ApplyAndReturn defect). + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:803` + +**Fix.** Same ApplyAndReturn fix. + +##### `PUT + DELETE rooms/{0}/promo_external` — SHAPE_MISMATCH (degraded) + +Twins exist for both verbs. PUT stores the form dict verbatim, so Type is persisted as the STRING "1" while the client's EPPPACFECMH reader expects Type as Int32 — the strict value reader will reject or zero it when details are re-read. DELETE reads keys id/url/value but the client sends type+reference → always 400 missing_promo_external, so promo links can't be removed. Response is ToWireRoom. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:885` + +**Fix.** PUT: build {Type=(int), Reference=(string)} explicitly from form. DELETE: match on form type+reference. Return full details. + +##### `PUT + DELETE rooms/{0}/promo_images` — SHAPE_MISMATCH (degraded) + +Twins for POST/PUT/DELETE all present, form key imageName read on both verbs — request side fully OK. Only defect: ToWireRoom response (ApplyAndReturn). + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:851` + +**Fix.** Same ApplyAndReturn fix. + +##### `GET rooms/{0}/roles` — MISSING (degraded) + +Handler + wire shape are correct ([{AccountId,Role,InvitedRole,LastChangedByAccountId}], BuildRoomAccountRoleWire :4003-4014) but there is no roomserver twin → the permissions screen's list fetch 404s under the 2023 prefix. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3695` + +**Fix.** Add [HttpGet("roomserver/rooms/{roomId:long}/roles")] (and twins for the two routes below). + +##### `GET + PUT rooms/{0}/roles/{1}` — MISSING (degraded) + +Handlers are contract-correct: GET returns a single role wire object; PUT reads form 'role' (ReadClientRoomRoleAsync :3944-3973) and returns full BuildRoomServerDetailsWithRolesAsync. But neither has a roomserver twin → 404 under the 2023 prefix. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3818 (GET), 3835 (PUT)` + +**Fix.** Add roomserver twins for GET and PUT/POST. + +##### `PUT rooms/{0}/roles/{1}/invite` — MISSING (degraded) + +Handler correct (form role, Accepted=false row, full-details response); no roomserver twin → 404 under prefix. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3879` + +**Fix.** Add roomserver twin. + +##### `POST rooms/{0}/subrooms/{1}/clone` — SHAPE_MISMATCH (degraded) + +Twins + empty-body POST OK; clone logic sound; but returns SceneWire instead of the full FGCPNAACHIK room details → client reports failure after a successful duplicate. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3483` + +**Fix.** Return BuildRoomServerDetailsWithRolesAsync(roomId). + +##### `POST rooms/{0}/subrooms/{1}/move` — SHAPE_MISMATCH (degraded) + +Twins exist but the semantics are wrong: the client sends form 'newRoomId' (Nullable) to move a subroom to ANOTHER ROOM; the handler binds [FromBody] SubRoomMoveRequest{NewIndex} and reorders scenes within the same room. Form body also 415s. Response SceneWire. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3543` + +**Fix.** Read form newRoomId; when present re-parent the RoomSceneEntity to the target room (owner-gated on both, next OrderIndex there); return full details. + +##### `PUT rooms/{0}/subrooms/{1}/name` — SHAPE_MISMATCH (degraded) + +roomserver twins registered. Two residual defects: the action mixes [FromForm] name params with a [FromBody] RenameBody — the [FromBody] binder puts the form PUT at risk of the repo-documented 415; and the response is a minimal {Result,RoomId,SubRoomId,Name,...} object, not the full FGCPNAACHIK details the client re-parses. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2656` + +**Fix.** Drop the [FromBody] parameter; return BuildRoomServerDetailsWithRolesAsync. + +##### `PUT rooms/{0}/subrooms/{1}/permissions` — STUB (degraded) + +Twins exist but the handler accepts SubRoomModifyRequest (Name/MaxPlayers/IsSandbox/CanMatchmakeInto) — the client actually sends a serialized LIST of BNLPBMJJOMM permission entries {Role,Type,Permission,Override,Value}. No permission matrix is ever stored (there is no storage for it), and the client body would 415/no-op. Response SceneWire. The role-permission matrix silently never saves. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3391` + +**Fix.** Parse the request as a list of {Role,Type,Permission,Override,Value}; persist per-subroom permission rows (new table); echo full room details. Scope explicitly if deferred — currently it is a silent stub. + +##### `POST rooms/{0}/subrooms/{1}/publish_save` — SHAPE_MISMATCH (degraded) + +Twins + form key subRoomDataSaveId are handled (ReadPublishSubRoomSaveRequestAsync :3440-3478); restore logic sets the scene blob. Only defect: SceneWire response instead of full details → restore shows failure in the client after succeeding. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:3404` + +**Fix.** Return BuildRoomServerDetailsWithRolesAsync. + +##### `PUT rooms/{0}/voice_chat_encryption` — STUB (degraded) + +Same as comments: no roomserver twin, no-op BareAck handler, EncryptVoiceChat never persisted and hardcoded false on the wire (RoomService.cs:1123, RoomsController.cs:2026); client form key encryptVoiceChat never read. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs:942` + +**Fix.** Persist an EncryptVoiceChat column; read form encryptVoiceChat; add twins; full-details response. + +### Room currencies, consumables and keys + +`room-economy` + +All 25 real client routes were checked against DorkNet. RoomConsumables routes exist but three of them (consume, purchase/tokens, purchase/currency) only map POST while the client PUTs (405), and every consumable response embeds a nested PriceAndCurrency object where the client's response formatter (FCIBLPCOODP) reads FLAT Price/PurchaseCurrencyId/ModifiedAt — so all shop prices deserialize as 0/token. RoomCurrencies is the worst area: every response DTO uses server-invented key names (RoomCurrencyId/DailyLimit/UpdatedAt/PurchaseOfferId/Amount/PlayerId) that share almost no keys with the client formatters (CurrencyId/CurrencyType/Limit/ModifiedAt/CurrencyPurchaseOfferId/CurrencyAmount/Order/AccountId), awardCurrency/bulk cannot parse the client's JSON-array body at all, getPurchaseOffersBatch ignores the client's "ids" param and returns an ungrouped flat list, and v2/purchase returns a shape with zero keys in common with EGHOOCGCKAD. RoomKeys: all four edit routes (PUT updateAll/updateName/updateDescription/updatePrice) are missing (server only has POST v1/update), owns/bulk never reads the JSON array body and answers with "Owns" instead of "DoesPlayerOwnRoomKey", owns/purchased/mine have caller-vs-target semantic mismatches, and ToWire drops PurchaseCurrencyId/CreatedAt/ImageName. Only 5 routes are fully OK: consumable delete, consumable isOwned, roomkeys delete, roomcurrencies deletePurchaseOffer, and (verb/shape-wise) nothing else without caveats. + +**Client-side notes.** VERB ENUM (ground truth): BNDIAONDFFF ctor (C:\tmp\recnet-runtime-decomp\BNDIAONDFFF.cs:194) takes BestHTTP.HTTPMethods (C:\tmp\recnet-runtime-decomp\BestHTTP\HTTPMethods.cs): 0=Get, 1=Head, 2=Post, 3=Put, 4=Delete. KEY CASING: every generated formatter carries three casing variants per key (PascalCase/camelCase/all-lowercase), so reads are casing-tolerant across those three exact forms, but the key NAME must match — unknown keys are skipped and missing value-type fields silently default. SERVER GAPS FOUND (DorkNet source cross-check): (1) RoomKeys update: client PUTs api/roomkeys/v1/updateAll|updateName|updateDescription|updatePrice; server only maps POST api/roomkeys/v1/update (DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:96) → all key edits 404. (2) owns/bulk: client POSTs a JSON array body [{RoomKeyId,AccountId}] which ReadRoomKeyIdsAsync (RoomKeysController.cs:215-232) never reads (query/form only), and the response key is "Owns" (RoomKeysController.cs:171) where the client reads "DoesPlayerOwnRoomKey" and also expects AccountId echoed. (3) RoomKeys ToWire (RoomKeysController.cs:180-190) omits PurchaseCurrencyId/CreatedAt/ImageName (defaults, non-fatal but lossy). (4) Consumables: client uses PUT for purchase/tokens, purchase/currency, and consume; server maps POST only (RoomConsumablesController.cs:230, :266, :307 — create/update at :139-140 already has PUT) → 405 on real client traffic. (5) Consumable RESPONSE desc must be FLAT (MOONCMIECPL formatter FCIBLPCOODP keys: Price, PurchaseCurrencyId, ModifiedAt); server ToDescWire (RoomConsumablesController.cs:407-414) sends nested PriceAndCurrency, so prices/currency deserialize as 0/null in catalog, /me, and edit responses. The nested PriceAndCurrency shape is correct ONLY for the create/update REQUEST DTO (JPFDLKMGKHF) and ExpectedPriceAndCurrency purchase bodies. docs/recroom-2023-room-consumables.md is wrong on both points (says POST, and attributes the nested desc to the response DTO) — update it. (6) RoomCurrencies responses are wrong nearly everywhere (RoomCurrenciesController.cs): currency ToWire (:376) sends RoomCurrencyId/Id/DailyLimit/UpdatedAt but client reads CurrencyId/Limit/ModifiedAt/CurrencyType (send CurrencyType=300 RoomCurrency); getBalance/getAllBalances (:82,:105) send PlayerId/RoomCurrencyId but client reads AccountId/CurrencyId/Balance/ModifiedAt; getBalance also ignores the client's "accountId" query param (server reads playerId); offer ToOfferWire (:391) sends PurchaseOfferId/Amount/CreatedAt but client reads CurrencyPurchaseOfferId/CurrencyId/CurrencyAmount/Order/ModifiedAt; getPurchaseOffersBatch takes param "ids" (room-currency Guids; server reads purchaseOfferIds/roomCurrencyIds → always empty filter) and must return List of {CurrencyId, PurchaseOffers:[...]} groups, not a flat offer list; awardCurrency/bulk request is a JSON ARRAY body [{TransactionId,RecipientId,Amount,CurrencyId}] which ReadFieldsAsync (:294-331) drops (object-root only) and the response must be [{AccountId,CurrencyId,Success,Error,Response:{AccountId,CurrencyId,Balance,AmountAwarded,AwardedAt}}]; roomCurrencies/v2/purchase response must be {CurrencyBalanceResponse:{AccountId,CurrencyId,Balance,ModifiedAt}, TokenBalanceResponse:{Balance,CurrencyType,Platform}} not the current {Success,Balance,...}. (7) Non-route literals seen alongside: "RoomCurrencyCreated"/"RoomCurrencyModified"/"RoomCurrencyDeleted" (MIHKAJFMIPE.txt:224-248) are in-client event/notification names, not HTTP routes. Storefront room-key purchasing (api/storefronts/v1/buyRoomKey GET!, api/storefronts/v1/PurchaseRoomKeyWithCurrency POST with RoomKeyId/RequestedPrice/RequestedPurchaseCurrencyId — DCFKEFHJAGC.txt:7085/:7424) belongs to the storefronts subsystem but completes the key-buy flow. + +#### Endpoints + +| Verb | Route | Request | Response | +|---|---|---|---| +| POST | `api/roomCurrencies/v2/purchase` | params: "PurchaseOfferId" (Guid), "RequestedAmount" (Int64), "RequestedPrice" (Int64) | EGHOOCGCKAD: {"CurrencyBalanceResponse": {"AccountId": Int32, "CurrencyId": Guid, "Balance": Int64, "ModifiedAt": DateTime}, "TokenBalanceResponse": {"Balance": Int64, "CurrencyType": Int32 (EAFDEJBEFJB, RecCenterTokens= | +| PUT | `api/roomconsumables/v1/roomConsumable` | JSON body JPFDLKMGKHF: {"RoomConsumableId": Guid (absent/empty on create, set on update), "RoomId": Int64, "Name": String, "Description": String, "ImageName": String, "PriceAndCurr | GFKDAADENEE: {"Status": Int32 (enum DLDNGEDFMOJ, Success=0), "Consumable": MOONCMIECPL} where the RESPONSE desc is FLAT: {"RoomConsumableId": Guid, "RoomId": Int64, "Name": String, "Description": String, "ImageName": Str | +| GET | `api/roomconsumables/v1/roomConsumable/room/{roomId} (composed)` | none | JSON array of MOONCMIECPL (flat desc: RoomConsumableId, RoomId, Name, Description, ImageName, Price, PurchaseCurrencyId, ModifiedAt) | +| GET | `api/roomconsumables/v1/roomConsumable/room/{roomId}/me (composed)` | none | JSON array of HOMJKAOHGDG: [{"RoomConsumableId": Guid, "AccountId": Int32, "Count": Int32, "ConcurrencyCode": Guid, "ModifiedAt": DateTime, "Consumable": MOONCMIECPL (never null)}] | +| DELETE | `api/roomconsumables/v1/roomConsumable/{id} (composed from prefix)` | none (id in path) | bare Int32 status body (enum DLDNGEDFMOJ; deserialized as FGLDKEJLAKB) | +| PUT | `api/roomconsumables/v1/roomConsumable/{id}/consume (composed)` | JSON body CFOBCEPBBHA: {"CurrentConcurrencyCode": Guid?, "NewConcurrencyCode": Guid} — client GENERATES NewConcurrencyCode and adopts it locally; server must store it verbatim or n | BGHBAILNNJJ: {"Status": Int32 (DLDNGEDFMOJ), "InventoryItem": HOMJKAOHGDG (current row; returned on mismatch for resync)} | +| GET | `api/roomconsumables/v1/roomConsumable/{id}/isOwned (composed)` | none | bare JSON boolean | +| PUT | `api/roomconsumables/v1/roomconsumable/{0}/purchase/currency` | JSON body ILNJDMFNOCD: {"ConcurrencyCodes": {...}, "ExpectedPriceAndCurrency": {"Price": Int64, "CurrencyId": Guid}} | DEILBLCDNEA: {"OperationResult": Int32 (DLDNGEDFMOJ), "BalanceUpdateResult": Int32? (enum GACBLALELBP: 0 Success, 1 NotEnoughCredit), "CurrencyBalanceResponse": {"AccountId": Int32, "CurrencyId": Guid, "Balance": Int64, | +| PUT | `api/roomconsumables/v1/roomconsumable/{0}/purchase/tokens` | JSON body ILNJDMFNOCD: {"ConcurrencyCodes": {"CurrentConcurrencyCode": Guid?, "NewConcurrencyCode": Guid}, "ExpectedPriceAndCurrency": {"Price": Int64, "CurrencyId": null}} | MJKFEHPIDME: {"OperationResult": Int32 (DLDNGEDFMOJ), "BalanceUpdateResult": Int32? (enum CABBDKFODEC: 0 OK, 2 NotEnoughCredit, 6 RequestedPriceDoesNotMatch...), "TokenBalanceResponse": {"Balance": Int64, "CurrencyType": | +| POST | `api/roomcurrencies/v1/awardCurrency/bulk` | JSON body = bare ARRAY of KOEIABFLEPM: [{"TransactionId": Guid, "RecipientId": Int32, "Amount": Int64, "CurrencyId": Guid}] — serialized then BNDIAONDFFF.FJLLPHFOOJJ(SetBody); NOT | JSON array of KBPKAAIDCIJ: [{"AccountId": Int32, "CurrencyId": Guid, "Success": Boolean, "Error": String, "Response": {"AccountId": Int32, "CurrencyId": Guid, "Balance": Int64, "AmountAwarded": Int64, "AwardedAt": DateTi | +| POST | `api/roomcurrencies/v1/createCurrency` | form/query params via AFGEDDANEKP: "RoomId" (Int64), "Name" (String), "Description" (String), "Limit" (Int64), "ImageName" (String, optional) | single KDEOPJCNKMC object (same 9 keys as /currencies rows: CurrencyId, RoomId, Name, Description, CurrencyType, Limit, ImageName, CreatedAt, ModifiedAt) | +| POST | `api/roomcurrencies/v1/createPurchaseOffer` | params: "CurrencyId" (Guid), "Name" (String), "Amount" (Int64), "Price" (Int64, tokens), "Order" (Int32) | single CJPPFBEAAHD: {"CurrencyPurchaseOfferId": Guid, "CurrencyId": Guid, "Order": Int32, "Name": String, "CurrencyAmount": Int64, "Price": Int64, "ModifiedAt": DateTime} | +| GET | `api/roomcurrencies/v1/currencies` | query param "roomId" (Int64) — added via BNDIAONDFFF.AFGEDDANEKP | JSON array of currency DTO KDEOPJCNKMC: [{"CurrencyId": Guid, "RoomId": Int64? (nullable), "Name": String, "Description": String, "CurrencyType": Int32 enum EAFDEJBEFJB (RoomCurrency=300), "Limit": Int64, "ImageName": St | +| POST | `api/roomcurrencies/v1/deletePurchaseOffer` | param: "PurchaseOfferId" (Guid) | ignored by client (any 200 works) | +| GET | `api/roomcurrencies/v1/getAllBalances` | query param "roomId" (Int64) | JSON array of LGAMPMJNGFH: [{"AccountId": Int32, "CurrencyId": Guid, "Balance": Int64, "ModifiedAt": DateTime}] | +| GET | `api/roomcurrencies/v1/getBalance` | query params "currencyId" (Guid) and "accountId" (Int32) — note camelCase on the wire | single LGAMPMJNGFH: {"AccountId": Int32, "CurrencyId": Guid, "Balance": Int64, "ModifiedAt": DateTime} | +| GET | `api/roomcurrencies/v1/getPurchaseOffersBatch` | repeated/list query param "ids" = room-currency Guids (verb chosen dynamically by helper ALHIJCJOLCB.JIECAFGCODK(ids, 100) — GET for small batches; serve GET and POST) | JSON array GROUPED BY CURRENCY, List: [{"CurrencyId": Guid, "PurchaseOffers": [CJPPFBEAAHD objects]}] — NOT a flat offer list | +| POST | `api/roomcurrencies/v1/updateCurrency` | params: "CurrencyId" (Guid) + optional "Name", "Description", "Limit" (Int64), "ImageName" (only sent when non-null) | single KDEOPJCNKMC object (see /currencies) | +| POST | `api/roomcurrencies/v1/updatePurchaseOffer` | params: "PurchaseOfferId" (Guid) + optional "Name", "Amount" (Int64), "Price" (Int64), "Order" (Int32) | single CJPPFBEAAHD (same keys as createPurchaseOffer) | +| POST | `api/roomkeys/v1/create` | params: "RoomId" (Int64), "Name", "Description", "Price" (Int32), "ImageName", "PurchaseCurrencyId" (Guid as string, "" when null) | ONLIDBLNMCC: {"Status": Int32 (enum GDIOEPIOPEE: 0 Success, 3 NameTooShort, 5 DuplicateName, 10/11 PriceTooLow/High, 12 PermissionDenied...), "RoomKey": {"RoomKeyId": Int64, "ReplicationId": Guid, "RoomId": Int64, "Name" | +| DELETE | `api/roomkeys/v1/delete/{roomKeyId} (composed)` | none (id in path) | bare Int32 status body (enum GDIOEPIOPEE) | +| GET | `api/roomkeys/v1/mine` | none | JSON array of JBOBOABIEBN room-key DTOs | +| GET | `api/roomkeys/v1/owns` | query params "playerId" (Int32) and "roomKeyId" (Int64) | bare JSON boolean | +| POST | `api/roomkeys/v1/owns/bulk` | JSON body = bare ARRAY of DIJEKBHKJDD: [{"RoomKeyId": Int64, "AccountId": Int32}] (serialized then SetBody FJLLPHFOOJJ — NOT query/form) | JSON array of NJBNPAOKNEE: [{"RoomKeyId": Int64, "AccountId": Int32, "DoesPlayerOwnRoomKey": Boolean}] — all three are non-nullable value types in the DTO; echo request pairs back | +| GET | `api/roomkeys/v1/purchased/{roomKeyId} (composed)` | none | bare JSON boolean | +| GET | `api/roomkeys/v1/room?roomId={id} (composed via NHNGFFFCBPD)` | query roomId (baked into route string) | JSON array of JBOBOABIEBN room-key DTOs (RoomKeyId/ReplicationId/RoomId/Name/Description/Price/PurchaseCurrencyId/CreatedAt/ImageName) | +| PUT | `api/roomkeys/v1/updateAll \| updateName \| updateDescription \| updatePrice (composed, 4 routes)` | PUT with params: "RoomKeyId" (Int64) always, plus per-action: updateAll={Name, Description, Price, PurchaseCurrencyId ("" if null), ImageName}; updateName={Name}; updateDescription | ONLIDBLNMCC {"Status", "RoomKey"} — same as create | + +#### Not HTTP routes + +These literals look like paths but are cache keys, deeplinks or MIME types: + +- `api/roomconsumables` — n/a — prefix literal +- `api/roomkeys/` — n/a — prefix literal +- `api/roomkeys/v1/` — n/a — prefix; concatenated with an action string to form the real update routes (see updateAll entry) + +#### Defects + +##### `GET api/roomcurrencies/v1/currencies` — SHAPE_MISMATCH (breaks-gameplay) + +Handler exists (GET, reads roomId query — both OK), but ToWire (RoomCurrenciesController.cs:376-389) emits RoomCurrencyId/Id/InternalId/CreatorPlayerId/DailyLimit/UpdatedAt. Client formatter PEOAOMGOMGC reads CurrencyId, RoomId, Name, Description, CurrencyType, Limit, ImageName, CreatedAt, ModifiedAt (verified PEOAOMGOMGC.txt:627-798). Missing CurrencyId means every currency deserializes with Guid.Empty as its id, so all downstream balance/offer lookups key on Guid.Empty; CurrencyType defaults to 0 instead of 300 (RoomCurrency), Limit and ModifiedAt default. + +Handler: `DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:18` + +**Fix.** Rewrite ToWire to { CurrencyId = currency.PublicId, RoomId (nullable long), Name, Description, CurrencyType = 300, Limit = currency.DailyLimit (long), ImageName, CreatedAt, ModifiedAt = currency.UpdatedAt }. + +##### `POST api/roomcurrencies/v1/createCurrency` — SHAPE_MISMATCH (breaks-gameplay) + +Route+verb OK, RoomId/Name/Description/ImageName read OK (case-insensitive dict), but the client sends "Limit" (Int64) and the server only reads dailyLimit/DailyLimit (line 48) so the limit is silently dropped to 0. Response uses the broken ToWire — the creator's client never learns the new currency's CurrencyId (Guid.Empty). + +Handler: `DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:30` + +**Fix.** Add "Limit" (and "limit") to the ReadInt/ReadLong alias list at line 48 (store as long), and fix ToWire as above. + +##### `POST api/roomcurrencies/v1/awardCurrency/bulk` — SHAPE_MISMATCH (breaks-gameplay) + +Client serializes a bare JSON ARRAY body [{TransactionId, RecipientId, Amount, CurrencyId}] and PUTs it via SetBody (verified: MIHKAJFMIPE.txt route at instr 037, verb 2 at 039, serialize+FJLLPHFOOJJ at 055-061; request formatter GFKKOADFJKC keys TransactionId/RecipientId/Amount/CurrencyId). Server ReadFieldsAsync (lines 294-331) only enumerates a JSON OBJECT root — an array root yields zero fields — so FindCurrencyAsync returns null and every award 404s. Even if parsed, the handler reads playerIds/amount (single amount for all) and responds [{PlayerId, RoomCurrencyId, Balance}] where the client formatter JCPFCOEEELK reads AccountId/CurrencyId/Success/Error/Response and nested GAGIJHJGFJD reads AccountId/CurrencyId/Balance/AmountAwarded/AwardedAt (verified). CircuitsV2 award chips are fully dead. + +Handler: `DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:113` + +**Fix.** In AwardCurrencyBulk, parse the raw body as a JSON array of {TransactionId:Guid, RecipientId:int, Amount:long, CurrencyId:Guid}; per element resolve the currency by PublicId, apply AddBalanceAsync per recipient, and return [{AccountId, CurrencyId, Success:bool, Error:string, Response:{AccountId, CurrencyId, Balance, AmountAwarded, AwardedAt}}]. + +##### `GET api/roomcurrencies/v1/getBalance` — SHAPE_MISMATCH (breaks-gameplay) + +Route+verb OK and "currencyId" is accepted by FindCurrencyAsync. Two defects: (1) client sends query "accountId" (verified MIHKAJFMIPE.txt instrs 051/063 this session — can target OTHER players) but server reads only playerId/PlayerId (line 80) and falls back to the caller, so circuits querying another player's balance silently get the caller's. (2) Response {PlayerId, RoomCurrencyId, Balance} vs client formatter DPNLANKOHON reading AccountId/CurrencyId/Balance/ModifiedAt (verified DPNLANKOHON.txt:331-414) — AccountId=0 and CurrencyId=Guid.Empty so the client cannot attribute the balance it receives. + +Handler: `DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:74` + +**Fix.** Read "accountId" (add to the ReadLong aliases at line 80) and respond { AccountId, CurrencyId = currency.PublicId, Balance, ModifiedAt }. + +##### `GET api/roomcurrencies/v1/getAllBalances` — SHAPE_MISMATCH (breaks-gameplay) + +Route/verb/roomId OK. Response rows {PlayerId, RoomCurrencyId, Balance} — client reads AccountId/CurrencyId/Balance/ModifiedAt (DPNLANKOHON). CurrencyId deserializes as Guid.Empty on every row so the currency HUD cannot map any balance to a currency on room entry. + +Handler: `DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:90` + +**Fix.** Emit { AccountId = (int)playerId, CurrencyId = x.c.PublicId, x.b.Balance, ModifiedAt = x.b.UpdatedAt } per row. + +##### `POST api/roomcurrencies/v1/createPurchaseOffer` — SHAPE_MISMATCH (breaks-gameplay) + +Route/verb OK; CurrencyId/Name/Amount/Price are read. "Order" (Int32) is not read or persisted (entity has no Order). Response ToOfferWire (lines 391-404) emits PurchaseOfferId/RoomCurrencyPackageId/Id/InternalId/RoomCurrencyId/Amount/CurrencyType/CreatedAt/UpdatedAt — client formatter AALDCAANEMM reads CurrencyPurchaseOfferId, CurrencyId, Order, Name, CurrencyAmount, Price, ModifiedAt (verified AALDCAANEMM.txt:499-622). Only Name and Price land; the offer id and amount come back as Guid.Empty/0, so the creator UI shows a broken pack. + +Handler: `DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:137` + +**Fix.** Add an Order int column to RoomCurrencyPurchaseOfferEntity (read "Order"/"order"), and change ToOfferWire to { CurrencyPurchaseOfferId = offer.PublicId, CurrencyId = currency.PublicId, Order, Name, CurrencyAmount = offer.Amount (long), Price (long), ModifiedAt = offer.UpdatedAt }. + +##### `GET (POST fallback for big batches) api/roomcurrencies/v1/getPurchaseOffersBatch` — SHAPE_MISMATCH (breaks-gameplay) + +GET+POST both mapped (good — client picks verb dynamically via ALHIJCJOLCB.JIECAFGCODK). Two defects: (1) client sends the list param "ids" containing room-currency Guids (verified MIHKAJFMIPE_NestedType_OBLJADOFIED.txt instr 066 route / 076 "ids", this session); server reads purchaseOfferIds/roomCurrencyIds/offerIds/currencyIds (lines 202-203) so both filters stay empty and it dumps up to 200 arbitrary offers. (2) Response must be GROUPED: List<{CurrencyId, PurchaseOffers:[offer]}> per formatter LKBGCOELLIN (verified: only keys CurrencyId/PurchaseOffers), but the server returns a flat ToOfferWire list — the client deserializes rows with CurrencyId=Guid.Empty and PurchaseOffers=null, so the This Room pack list is empty/garbage. + +Handler: `DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:196` + +**Fix.** Add "ids"/"Ids" to the currency-id alias list, group results by currency PublicId, and return rows.GroupBy(...).Select(g => new { CurrencyId = g.Key, PurchaseOffers = g.Select(fixed ToOfferWire) }). + +##### `POST api/roomCurrencies/v2/purchase` — SHAPE_MISMATCH (breaks-gameplay) + +POST mapped, PurchaseOfferId lookup works. RequestedAmount/RequestedPrice are ignored (no price-agreement validation — degraded but not fatal). Fatal part: response is {Success, Balance, CurrencyType, RoomCurrencyId, RoomCurrencyBalance} while the client formatter KNGMIABFJHK reads exactly CurrencyBalanceResponse + TokenBalanceResponse (verified), with nested DPNLANKOHON {AccountId,CurrencyId,Balance,ModifiedAt} and PECGEJAAMHB {Balance,CurrencyType,Platform} (verified). Zero keys match, so after buying a pack the client sees null balances — the token counter and currency balance never update and the buy UI errors. + +Handler: `DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:216` + +**Fix.** Validate RequestedPrice==offer.Price (and RequestedAmount==offer.Amount), then return { CurrencyBalanceResponse = { AccountId=(int)pid, CurrencyId=currency.PublicId, Balance=customBalance, ModifiedAt=UtcNow }, TokenBalanceResponse = { Balance=newBalance, CurrencyType=2, Platform=0 } }. The insufficient-funds path must also emit this shape (with the unchanged balances) — the current {Success=false,...} anonymous object is equally unreadable. + +##### `GET api/roomconsumables/v1/roomConsumable/room/{roomId}` — SHAPE_MISMATCH (breaks-gameplay) + +Route/verb OK, real data. But every row goes through nested ToDescWire while the client reads flat Price/PurchaseCurrencyId/ModifiedAt (FCIBLPCOODP.txt:670-710, verified). Every item in every room shop deserializes with Price=0 and PurchaseCurrencyId=null, i.e. the whole catalog appears token-priced at 0 — the room economy is effectively free and currency-priced items take the wrong purchase path. + +Handler: `DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs:80` + +**Fix.** Same ToDescWire flattening as above (single shared fix). + +##### `PUT api/roomconsumables/v1/roomConsumable/{id}/consume` — VERB_MISMATCH (breaks-gameplay) + +Client PUTs (verified this session: LPNHMEFDAAG.txt:1449-1460, route format "{0}/v1/roomConsumable/{1}/consume" + verb literal 3 at instr 065). Server maps [HttpPost] only → ASP.NET returns 405 Method Not Allowed and no consumable can ever be consumed. Handler logic and the {Status, InventoryItem} response otherwise match LPFAFIGAIOE (concurrency-code store-verbatim behavior is already correct), apart from the nested Consumable desc issue. + +Handler: `DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs:307` + +**Fix.** Add [HttpPut("api/roomconsumables/v1/roomConsumable/{publicId:guid}/consume")] alongside the existing HttpPost. + +##### `PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/tokens` — VERB_MISMATCH (breaks-gameplay) + +Client PUTs (verified this session: DCFKEFHJAGC.txt instrs 097-108 — route literal + verb 3). Server maps [HttpPost] only → 405; no token-priced consumable can be bought. The response builder TokenResponse (lines 382-392) otherwise matches HJOKFJKKEJM/PECGEJAAMHB exactly (verified key sets this session). + +Handler: `DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs:230` + +**Fix.** Add [HttpPut] for the same route. + +##### `PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/currency` — VERB_MISMATCH (breaks-gameplay) + +Client PUTs (verified this session: DCFKEFHJAGC.txt instrs 112-123 — route literal + verb 3). Server maps [HttpPost] only → 405; currency-priced consumables unbuyable. CurrencyResponse shape (lines 394-405) matches the claimed IDBFKCFEAHM/DPNLANKOHON shape. Secondary nit: mismatch paths return BadRequest("price_mismatch") instead of an OperationResult body — the client's strict reader gets a 400 with a non-DTO body; prefer returning the DTO with a failure OperationResult. + +Handler: `DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs:266` + +**Fix.** Add [HttpPut] for the same route; optionally convert the BadRequest branches to 200 + DTO. + +##### `PUT api/roomkeys/v1/updateAll \| updateName \| updateDescription \| updatePrice` — MISSING (breaks-gameplay) + +Client builds "api/roomkeys/v1/" + action with verb PUT and param "RoomKeyId" (verified this session: AHMBBJNANBP.txt instr 058 concat prefix, 067 verb 3, 079 "RoomKeyId"; action literals at :1345 updateAll, :1559 updateName, :1772 updateDescription, :2043 updatePrice). The server registers only POST api/roomkeys/v1/update — none of the four real routes exist, so every room-key edit 404s. Per-action fields: updateAll={Name,Description,Price,PurchaseCurrencyId,ImageName}, updateName={Name}, updateDescription={Description}, updatePrice={Price,PurchaseCurrencyId}. Response must be the {Status, RoomKey} envelope. + +Handler: `none (nearest: POST api/roomkeys/v1/update, DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:96)` + +**Fix.** Add [HttpPut] routes api/roomkeys/v1/updateAll, /updateName, /updateDescription, /updatePrice (can share the existing Update handler with a partial-update flag per action), reading RoomKeyId + the per-action fields from query/form, returning RoomKeyResponse. + +##### `POST api/roomkeys/v1/owns/bulk` — SHAPE_MISMATCH (breaks-gameplay) + +Verb OK (POST mapped; verified client POSTs with a serialized JSON ARRAY body via SetBody — AHMBBJNANBP_NestedType_JIPDBBEFAPN.txt instrs 044-065, read this session). Three defects: (1) request body [{RoomKeyId:Int64, AccountId:Int32}] is never read — ReadRoomKeyIdsAsync (lines 215-232) parses query and form only, so ids is always empty and the handler returns [] for every batch; (2) even with ids, the server checks only the CALLER's ownership while the request pairs name arbitrary AccountIds; (3) response key is "Owns" (line 171) and AccountId is absent, but the client formatter KHFKIJEGPDG reads RoomKeyId/AccountId/DoesPlayerOwnRoomKey, all non-nullable (verified KHFKIJEGPDG.txt:279-346) — "Owns" is skipped as unknown and DoesPlayerOwnRoomKey defaults to false. Batched key-door resolution is fully dead. + +Handler: `DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:159` + +**Fix.** Parse the JSON body as an array of {RoomKeyId:long, AccountId:int}, query RoomKeyPurchases for each (AccountId, RoomKeyId) pair, and echo [{RoomKeyId, AccountId, DoesPlayerOwnRoomKey}] per request element. + +##### `POST api/roomcurrencies/v1/updateCurrency` — SHAPE_MISMATCH (degraded) + +Route+verb OK; FindCurrencyAsync (line 245) accepts "CurrencyId" so lookup works. But optional "Limit" is ignored (server reads dailyLimit only, line 68) and the response is the broken ToWire, so the edit UI redisplays wrong data (Limit=0, CurrencyId=empty). + +Handler: `DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:55` + +**Fix.** Read "Limit" alias in UpdateCurrency; fix ToWire. + +##### `POST api/roomcurrencies/v1/updatePurchaseOffer` — SHAPE_MISMATCH (degraded) + +Route/verb OK; PurchaseOfferId lookup works via FindOfferAsync (line 256), Name/Amount/Price read. "Order" is silently dropped and the response is the same broken ToOfferWire (CurrencyPurchaseOfferId/CurrencyAmount/CurrencyId/Order/ModifiedAt all default), so the pack list re-renders wrong after every edit. + +Handler: `DorkNet.Server/Controllers/API/RoomCurrencies/RoomCurrenciesController.cs:159` + +**Fix.** Read/persist Order; fix ToOfferWire (same change as createPurchaseOffer). + +##### `PUT api/roomconsumables/v1/roomConsumable (create/update)` — SHAPE_MISMATCH (degraded) + +PUT and POST both mapped (verb OK); request parsing handles the nested PriceAndCurrency{Price,CurrencyId} body correctly (ReadPriceAndCurrency:476). Defect is the response: EditResponse embeds ToDescWire (lines 407-415) which nests PriceAndCurrency{Price,CurrencyId}, but the client's RESPONSE formatter FCIBLPCOODP reads FLAT keys Price, PurchaseCurrencyId, ModifiedAt (verified FCIBLPCOODP.txt:670-710 — no PriceAndCurrency key exists in that formatter). After saving, the editor shows Price=0, no currency, default ModifiedAt. + +Handler: `DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs:139` + +**Fix.** Change ToDescWire to flat: { RoomConsumableId, RoomId, Name, Description, ImageName, Price, PurchaseCurrencyId = c.CurrencyId, ModifiedAt = c.UpdatedAt }. Keep the nested reader for REQUESTS only. Also update docs/recroom-2023-room-consumables.md, which wrongly documents the nested desc as the response shape. + +##### `GET api/roomconsumables/v1/roomConsumable/room/{roomId}/me` — SHAPE_MISMATCH (degraded) + +Route/verb OK; the inventory-row envelope (RoomConsumableId/AccountId/Count/ConcurrencyCode/ModifiedAt/Consumable, Consumable never null) matches CICEHLNDLDE exactly — no NRE risk. But the nested Consumable is the same nested-PriceAndCurrency desc, so owned items carry Price=0/PurchaseCurrencyId=null/ModifiedAt=default inside the inventory too. + +Handler: `DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs:95` + +**Fix.** Fixed automatically by the ToDescWire flattening. + +##### `POST api/roomkeys/v1/create` — SHAPE_MISMATCH (degraded) + +POST mapped, Status/RoomKey envelope and status enum match GDIOEPIOPEE. Three gaps: (1) request fields ImageName and PurchaseCurrencyId sent by the client are never read (ReadBodyAsync lines 234-286 only maps roomId/name/description/price), so keys lose their image and can never be room-currency-priced; (2) ToWire (lines 180-190) omits PurchaseCurrencyId, CreatedAt, ImageName which the client formatter ANHGLIFGACE reads (verified full key set this session) — they silently default; (3) ReadBodyAsync reads form or JSON body but never Request.Query — whether the 2023 client sends these as query string or form body is UNKNOWN (AFGEDDANEKP bodies are stripped in the decomp); the sibling RoomCurrenciesController reads both, RoomKeys should too for safety. + +Handler: `DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:61` + +**Fix.** Extend RoomKeyEntity + ReadBodyAsync with ImageName and PurchaseCurrencyId (Guid?, empty-string = null); add query-param fallback; extend ToWire with PurchaseCurrencyId, CreatedAt, ImageName. + +##### `GET api/roomkeys/v1/purchased/{roomKeyId}` — SHAPE_MISMATCH (degraded) + +Route/verb/bare-boolean OK, but the semantics are inverted: the client call-site is the creator asking "has ANYONE bought this key" before destructive edits, while the server answers "has the CALLER bought it" (p.PlayerId == pid, line 147). A creator never buys their own key, so this always returns false and the sold-stock warning never fires. + +Handler: `DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:143` + +**Fix.** Change the query to AnyAsync(p => p.RoomKeyId == roomKeyId) (optionally excluding the key's creator), no PlayerId filter. + +##### `GET api/roomkeys/v1/room?roomId={id}` — SHAPE_MISMATCH (degraded) + +Route/verb/array-shape OK; RoomKeyId/ReplicationId(Guid)/RoomId/Name/Description/Price present. ToWire omits PurchaseCurrencyId, CreatedAt, ImageName which the client formatter ANHGLIFGACE reads (verified) — key thumbnails blank, currency-priced keys indistinguishable from token-priced, CreatedAt defaults to DateTime.MinValue (non-nullable value type, silently defaulted, no crash). + +Handler: `DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:22` + +**Fix.** Extend ToWire (and the entity, per the create fix) with PurchaseCurrencyId (Guid?), CreatedAt, ImageName. + +##### `GET api/roomkeys/v1/mine` — SHAPE_MISMATCH (degraded) + +Route/verb/array OK, but semantics differ: the client call-site is the watch key ring — keys the caller OWNS/PURCHASED — while the server returns keys the caller CREATED (k.CreatorPlayerId == pid, line 39). Players see none of their bought keys (and creators see keys they don't hold). Also inherits the lossy ToWire. + +Handler: `DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:34` + +**Fix.** Join RoomKeyPurchases on PlayerId == caller and return the purchased keys' JBOBOABIEBN wires (decide whether creators' own keys should also appear; purchased set is what the UI labels 'mine'). + +##### `GET api/roomkeys/v1/owns` — SHAPE_MISMATCH (degraded) + +Route/verb/bare-boolean OK, but the client sends BOTH "playerId" and "roomKeyId" query params and can ask about OTHER players (key-door gating); the server ignores playerId entirely and always answers for the caller (line 154-155). Key doors evaluating another player's ownership get the wrong answer whenever the target differs from the caller. + +Handler: `DorkNet.Server/Controllers/API/RoomKeys/RoomKeysController.cs:151` + +**Fix.** Accept [FromQuery] long? playerId and check ownership for playerId ?? caller. + +### Avatar, wardrobe, consumables and custom items + +`avatar-inventory` + +Verified all 37 real client HTTP calls against the DorkNet server source (every handler file opened and read; the regex route index was cross-checked against actual attributes). 20 routes are fully OK. 9 endpoints are MISSING/verb-less: POST api/customAvatarItems/v1 (create), PUT+DELETE .../v1/{id}, GET+PUT+DELETE .../v1/design, POST .../v1/{id}/report, POST econ/customAvatarItems/v1/{id}/purchase (exists only under api/), GET api/consumables/v1/getTransferable/{id}, GET api/ugcPurchasables/v1/items/room/{roomId} — together these kill the entire custom-shirt designer/purchase pipeline and the consumable-transfer and in-room-store list flows. 8 shape/binding mismatches: bare-scalar violations on econ itemOwnershipLimit (object vs Int32, EconController.cs:101) and isCreationAllowedForAccount (object vs Boolean, CustomAvatarItemsController.cs:194); fromCreator returns a bare array where the client parses paged {Results,TotalResults} (CustomAvatarItemsController.cs:131); customAvatarItems/v1/bulk cannot receive the client's 'customAvatarItemIds' key by body ([FromBody] whitelist + 415-on-form, :60-71/:303-326) so remote players' shirts never resolve; consumables/v1/transfer binds [FromForm] but the client sends a JSON body → 415 (InventoryController.cs:212); saved-outfit DTO drops OutfitSelectionsV2 + CustomAvatarItems on both GET and set (AvatarSavedOutfitsController.cs:95-103); equipment/v1/update binds one object where ISIL shows the client posting a JSON array (PLAUSIBLE — server comments imply a single object bound live; needs a trace); gifts/generate v2+v3 keep [FromBody] and would 415 on the form-urlencoded body the same client helper provably produced for gifts/consume (PLAUSIBLE). Minor: v2/getUnlocked consumables hardcodes IsActive/IsTransferable=false; lockeditems ships a 5-key subset of the shared owned-item DTO (null TagList risk); minPriceForPublicItem key match is UNKNOWN (single-Int32 client DTO, key not in ISIL). + +**Client-side notes.** HTTP layer: every request goes through request-builder BNDIAONDFFF; its ctor (native 0x1830036A0) takes the VERB ENUM in rdx and the route string in r9. Enum decoded from update/delete semantics across this subsystem: 0=GET, 2=POST, 3=PUT, 4=DELETE (e.g. customAvatarItems v1/{id} uses 3 for the update overload and 4 for delete; design save uses 3, design delete 4). Helpers: AFGEDDANEKP(key, boxedValue) adds a named body/query param (key literals visible in ISIL — the only place exact request keys can be read); FJLLPHFOOJJ sets a JSON body (JsonConvert via ALHIJCJOLCB or JsonUtility.ToJson); BPHHLAIILHP adds a multipart file part (always filename "file.bin"); AMDPEBKIHOH is the cached-GET variant; LBIIOPNIDAC.PBGNNIJLBDG is response-cache INVALIDATION — route strings passed to it (EAIGBBHMIKM.txt:2733, EAIGBBHMIKM_NestedType___c.txt:1335) are cache keys, not HTTP calls. Response DTO property getters are obfuscated auto-props with no key literals in ISIL (Newtonsoft reflection over name-preserved metadata), so exact response keys were grounded in the DorkNet server implementation that runs live against this exact client, which itself embeds per-key decompile citations (e.g. EKHJKDNHOPB.txt:471-718 for the avatar reader/writer). SERVER GAPS FOUND (client calls with no/incompatible server route): (1) GET api/consumables/v1/getTransferable/{id} — missing entirely; (2) POST api/customAvatarItems/v1 (create, multipart metadata+thumbnailImage) — no POST on collection; (3) PUT + DELETE api/customAvatarItems/v1/{id} — missing; (4) GET + PUT + DELETE api/customAvatarItems/v1/design — server only has POST; (5) POST api/customAvatarItems/v1/{id}/report — missing; (6) POST econ/customAvatarItems/v1/{id}/purchase/?requestedPrice= — purchase only exists under api/, not econ/; (7) GET api/ugcPurchasables/v1/items/room/{roomId} — server only accepts ?roomId= query form. SHAPE MISMATCHES (route exists but wire likely wrong): (a) econ/customAvatarItems/v1/itemOwnershipLimit — client parses BARE Int32, server sends {Limit,ItemOwnershipLimit} object; (b) api/customAvatarItems/v1/isCreationAllowedForAccount — client parses BARE Boolean, server sends an object (siblings isCreationEnabled/isRenderingEnabled correctly send bare booleans); (c) api/customAvatarItems/v2/fromCreator/{id} — client expects paged {Results,TotalResults} (CKFLFJCNEPH), server returns a bare array; (d) api/equipment/v1/update — client posts a JSON ARRAY (List), server binds a single object; (e) api/consumables/v1/transfer — client sends a JsonUtility JSON body, server only reads form/query; (f) api/customAvatarItems/v1/bulk — client's param key is "customAvatarItemIds", which the server's form-key whitelist does not include (works only if the client emits it as a query param); (g) saved outfits (BEPMJICDNDD) carry a 7th string (likely OutfitSelectionsV2) + CustomAvatarItems list that the server's SavedOutfitDto silently drops. The bare literals "api/avatar/", "api/consumables/", "api/customAvatarItems", "econ/customAvatarItems", "api/ugcPurchasables" are String.Format prefixes, not standalone routes. No key-literal evidence exists for GGKCJMIFLDJ (catalog SKU), NOAAFNFJPFB, CKFLFJCNEPH, JEFHGGIDKEG, NCMMAGDIBND, IHLMDLKOLNB — their key sets above come from the live-verified server wire (dual-cased where uncertain); treat unlabeled names as PLAUSIBLE, not CONFIRMED. + +#### Endpoints + +| Verb | Route | Request | Response | +|---|---|---|---| +| GET | `api/avatar/v1/defaultbaseavataritems` | none | JSON array of the same UnlockedAvatarItem shape as v4/items | +| GET | `api/avatar/v1/defaultunlocked` | none | Same array shape as v4/items (server aliases it to the same handler) | +| GET | `api/avatar/v1/lockeditems` | Query: repeated key "desc" carrying avatar-item desc strings (list added via helper 0x181F2E250 with key literal "desc") | JSON array; server ships subset {AvatarItemType, AvatarItemDesc, FriendlyName, Tooltip, Rarity} for each requested desc the caller does NOT own | +| GET | `api/avatar/v2` | none | Object with keys (exact literals in reader EKHJKDNHOPB, case-tolerant Pascal/camel/lower): OutfitSelections(string), OutfitSelectionsV2(string), FaceFeatures(string), SkinColor(string), HairColor(string), CustomAvatarIte | +| GET | `api/avatar/v2/gifts` | none | JSON array of gift objects. Server wire (verified live, DorkNet AvatarGiftsController.ToWire): Id(Int64), FromPlayerId(Int64?), ConsumableItemDesc(string), AvatarItemType(Int32), AvatarItemDesc(string, 4-comma-part 'guid | +| POST | `api/avatar/v2/gifts/consume/` | POST to the literal URL WITH trailing slash (no id in path). Params: "Id" (boxed Int64 gift id), "UnlockedLevel" (boxed Int32) — sent form-urlencoded (server had to accept form; [F | Gift object echo (server returns ToWire(gift)); client treats non-2xx as 'Gift is missing from the list.' error | +| POST | `api/avatar/v2/gifts/generate` | Body params (exact key literals from ISIL): "GiftContext" (boxed Int32 enum IFKEEPDDNBC), "IsGameGift" (boxed Boolean), "AlternateGiftContext" (boxed Nullable), "Message" (s | Single gift object, same shape as api/avatar/v2/gifts element | +| POST | `api/avatar/v2/set` | JSON body written by EKHJKDNHOPB writer, PascalCase keys: OutfitSelections, OutfitSelectionsV2, FaceFeatures, SkinColor, HairColor, CustomAvatarItems:[{CustomAvatarItemId, BodyPart | Client fires-and-forgets (void); server echoes AvatarV2Dto | +| GET | `api/avatar/v2/{playerId}` | none (player id is bare path segment under v2) | Same HHDLNAPEMGP object as api/avatar/v2 | +| POST | `api/avatar/v3/gifts/generate` | Body params: "GiftContext" (boxed Int32), "Message" (string, optional) | Single gift object (same as v2) | +| GET | `api/avatar/v3/saved` | none | JSON array of saved outfits. Server DTO: Slot(Int32), PreviewImageName(string), OutfitSelections(string), FaceFeatures(string), SkinColor(string), HairColor(string). NOTE: client BEPMJICDNDD has Int32 + SIX strings + Lis | +| POST | `api/avatar/v3/saved/set` | JSON body = one BEPMJICDNDD serialised via JsonConvert (ALHIJCJOLCB) then BNDIAONDFFF.FJLLPHFOOJJ; same field set as the GET element | Client ignores body (fire-and-forget task with error toast); server echoes the DTO | +| GET | `api/avatar/v4/items` | none | JSON array. Server DTO (verified live against this client): AvatarItemType(Int32), AvatarItemDesc(string, MUST have >=4 comma-parts 'guid,,,'), AvatarItemId(Int32 — must be unique+non-zero or wardrobe collapses), Platfor | +| GET | `api/catalog/v1/all?onlyAvailableSkus=true` | query baked into the literal: onlyAvailableSkus=true | JSON array of SKU objects. Exact client key set not in ISIL (reflection DTO); server ships dual-cased dictionary verified live — required keys include SkuId(Int32 — string here throws FormatException), Slug/Sku, Name, De | +| POST | `api/consumables/v1/consume` | JSON body = JsonUtility.ToJson(ConsumableItemRequest); server binds {Id(Int64), DeltaCount(Int32)} (client passes Mathf.Abs of the count) | Server returns {Id, Count}; client parse target is a string callback (Action) — tolerant | +| GET | `api/consumables/v1/getTransferable/{playerId}` | none (Int32 id is a path segment — likely the prospective recipient account id) | JSON array of the same bulk-consumable shape. SERVER GAP: no route registered for api/consumables/v1/getTransferable/* (server-routes.txt has only consume/getUnlocked/transfer/updateActive) — this call 404s | +| POST | `api/consumables/v1/transfer` | JSON body = JsonUtility.ToJson(TransferConsumableRequest) — field keys UNKNOWN from ISIL. LIKELY MISMATCH: server binds [FromForm] id/recipientPlayerId/quantity with a query fallba | Server returns {Success, SourceCount, RecipientCount}; client logs 'Failed to TransferConsumable' on error | +| POST | `api/consumables/v1/updateActive` | JSON body = JsonUtility.ToJson(ActivateConsumableItemRequest). Field keys not visible in ISIL; server binds {Id(Int64), IsActive(bool)} and works live | Client ignores; error toast 'Invalid consumable' on failure. Server returns {Id, IsActive} | +| GET | `api/consumables/v2/getUnlocked` | none | JSON array of BulkConsumable groups (PascalCase, verified live): Ids(Int64[]), CreatedAts(DateTime[]), ConsumableItemDesc(string), Count(Int32), InitialCount(Int32), IsActive(bool), ActiveDurationMinutes(Int32), IsTransf | +| POST | `api/customAvatarItems/v1` | multipart/form-data: named param "metadata" (JSON string) + file part "thumbnailImage" (filename "file.bin") added via BNDIAONDFFF.BPHHLAIILHP; two Byte[] args imply a second file | Single NOAAFNFJPFB custom-item object; server ToWire shape (used by sibling endpoints): Id/CustomAvatarItemId(Guid), CreatorId/CreatorPlayerId(Int32), Name, Description, Price(Int32), ItemType(Int32), BaseAvatarItemId(In | +| POST | `api/customAvatarItems/v1/bulk` | Param key literal "customAvatarItemIds" (guid list added via helper 0x181F2DCD0). CAUTION: server ReadIdsAsync reads form keys ids/Ids/itemIds/ItemIds and ALL query pairs, but NOT | JSON array of NOAAFNFJPFB custom-item objects | +| GET | `api/customAvatarItems/v1/design` | none | Single design-info object (4 props per ISIL getter set). SERVER GAP: only POST design exists; this GET 405s — client logs 'Unable to get custom avatar design info' | +| PUT | `api/customAvatarItems/v1/design` | multipart: named param "metadata" + file part "design" (filename "file.bin"). SERVER GAP: design route only accepts POST — the client's PUT 405s | status only (LDGADANDBIO) | +| DELETE | `api/customAvatarItems/v1/design` | none. SERVER GAP: no DELETE on design — client logs 'Unable to delete custom avatar item design' | status only | +| GET | `api/customAvatarItems/v1/featured` | none | JSON array of custom-item objects (server ToWire shape as listed for the create endpoint) | +| GET | `api/customAvatarItems/v1/hot` | none | JSON array of custom-item objects | +| GET | `api/customAvatarItems/v1/isCreationAllowedForAccount` | none | Client expects a BARE JSON boolean. LIKELY MISMATCH: server returns object {IsCreationAllowed, Allowed} — should be bare true/false like the sibling endpoints | +| GET | `api/customAvatarItems/v1/isCreationEnabled` | none | Bare JSON boolean; server correctly returns Content("true","application/json") | +| GET | `api/customAvatarItems/v1/isRenderingEnabled` | none | Bare JSON boolean; server returns bare true | +| GET | `api/customAvatarItems/v1/me` | Query params "skip" (boxed Int32), "take" (boxed Int32) | Paged object {Results:[NOAAFNFJPFB...], TotalResults:Int32} (server also emits extra Created/Owned arrays which the client ignores) | +| GET | `api/customAvatarItems/v1/minPriceForPublicItem` | none | Object with one int field; server hedges with {MinPrice:100, Price:100} — one of the two keys presumably matches the client's property name | +| PUT | `api/customAvatarItems/v1/{id}` | JSON body via FJLLPHFOOJJ carrying the changed fields (name/description/price/publish-state). SERVER GAP: only GET is registered on v1/{id:guid}; PUT 405s | Updated NOAAFNFJPFB object | +| DELETE | `api/customAvatarItems/v1/{id}` | none (guid in path). SERVER GAP: no DELETE registered — 405 | status only (client fire-and-forget) | +| POST | `api/customAvatarItems/v1/{id}/report` | JSON body via FJLLPHFOOJJ with report reason enum + free-text (exact keys UNKNOWN — body serialised from method args). SERVER GAP: no report route registered — 404 | status only | +| GET | `api/customAvatarItems/v2/fromCreator/{creatorId}` | none beyond path id | Client expects the PAGED container CKFLFJCNEPH {Results, TotalResults}. LIKELY MISMATCH: server FromCreator returns a bare JSON array (Ok(rows.Select(ToWire))) not the paged object | +| POST | `api/equipment/v1/update` | JSON body = serialised List (new List built at ISIL 038, JsonConvert via ALHIJCJOLCB then FJLLPHFOOJJ) — a JSON ARRAY of equipment objects. POSSIBLE MISMATCH: server b | Client fire-and-forget; error toast 'Failed to upload equipment slot updates'. Server returns {Updated:true} | +| GET | `api/equipment/v2/getUnlocked` | none | JSON array (verified live): PrefabName(string), ModificationGuid(string), PlatformMask(Int32), IsPlatformLocked(bool), FriendlyName(string), Tooltip(string), Rarity(Int32), Favorited(bool), Equipped(bool), IsEquipped(boo | +| POST | `api/ugcPurchasables/v1/items/bulk` | JSON body {"RoomId":N, "Ids":[{"itemType":Int32, "itemId":"guid"}]} — Ids is an array of OBJECTS, not flat guid strings (server comment documents this shape from live traffic; body | JSON array with one entry PER requested id, in request order (server synthesizes placeholders for unknown ids — missing entries make the client re-query forever and break player spawn) | +| GET | `api/ugcPurchasables/v1/items/room/{roomId}` | none beyond path roomId. SERVER GAP: only GET api/ugcPurchasables?roomId=N is registered; the /v1/items/room/{roomId} path form 404s | JSON array of ugc-purchasable objects; server wire: UgcPurchasableId/PurchasableItemId/Id(Guid), InternalId(Int64), RoomId(Int64), CreatorPlayerId(Int32), Name, Description, ImageName, Price(Int32), CurrencyType(Int32), | +| GET | `econ/customAvatarItems/v1/itemOwnershipLimit` | none | Client's declared response type is a BARE Int32 (method returns FGLDKEJLAKB). LIKELY MISMATCH: server returns object {Limit:1000, ItemOwnershipLimit:1000} — deserialising an object into Int32 fails; should be a ba | +| GET | `econ/customAvatarItems/v1/owned` | Query "skip", "take" | Paged {Results:[custom item wire...], TotalResults:Int32}; server econ wire per item: CustomAvatarItemId/Id(Guid), CreatorPlayerId(Int32), Name, Description, Price, ItemType, ImageName, AssetName, Color | +| POST | `econ/customAvatarItems/v1/{id}/purchase/?requestedPrice={price}` | none beyond path guid + query requestedPrice(Int32). SERVER GAP: purchase exists only under api/customAvatarItems/.../purchase; there is NO econ/customAvatarItems/v1/{id}/purchase | JEFHGGIDKEG object, exact keys UNKNOWN; the api-side server handler returns {Success, Balance, Item} / {Success, AlreadyOwned, Item} | + +#### Not HTTP routes + +These literals look like paths but are cache keys, deeplinks or MIME types: + +- `api/avatar/` — String.Format prefix producing api/avatar/v2/{playerId} +- `api/consumables/` — String.Format prefix producing api/consumables/v1/getTransferable/{id} +- `api/customAvatarItems` — URL composition +- `api/ugcPurchasables` — URL composition +- `econ/customAvatarItems` — URL composition + +#### Defects + +##### `POST api/customAvatarItems/v1` — MISSING (breaks-gameplay) + +The controller's bare collection route ([Route("api/customAvatarItems/v1")] class prefix) has only [HttpGet] List — there is no [HttpPost] on the collection, so the client's multipart create (named param 'metadata' JSON + file part 'thumbnailImage', filename file.bin, plus a second binary part for the item asset) gets 405 Method Not Allowed. Publishing a custom shirt from the in-game designer is impossible. (POST design at :221 is a different route and takes flat fields, not the multipart metadata+thumbnail contract.) + +Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:22` + +**Fix.** Add [HttpPost] Create to CustomAvatarItemsController: read Request.Form, JSON-parse the 'metadata' form field into the item fields (name/description/price/itemType/baseAvatarItemId/color/isPublic — capture the real metadata key names via request tracing on first client hit), persist Request.Form.Files parts ('thumbnailImage' → ImageName via the image store, remaining file part → AssetName), create the CustomAvatarItemEntity + creator ownership, return ToWire(item). + +##### `POST api/customAvatarItems/v1/bulk` — SHAPE_MISMATCH (breaks-gameplay) + +GET+POST bulk are registered, but the client's guid-list param key is 'customAvatarItemIds' (ISIL literal) and no server read path honors it as a body key: (a) [FromBody] BulkRequest only binds Ids/ItemIds, so a JSON body {"customAvatarItemIds":[...]} binds nothing; (b) the form-key whitelist at :319 is ids/Ids/itemIds/ItemIds only — and worse, a form-urlencoded POST never reaches it because the [FromBody] parameter makes ASP.NET reject non-JSON bodies with 415. Only a query-string encoding would work (ReadIdsAsync scans all query pairs at :311-314). Result: resolving custom shirts worn by other players returns [] (or 415), so remote players' shirts never render — the exact failure mode the UGC bulk endpoint's comment describes for its own past bug. + +Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:60` + +**Fix.** Mirror UgcPurchasablesController.Bulk: drop [FromBody], add [Consumes] for json+form, read the raw JSON body case-insensitively accepting customAvatarItemIds/ids/itemIds arrays, and add 'customAvatarItemIds'/'CustomAvatarItemIds' to the form-key whitelist. + +##### `PUT api/customAvatarItems/v1/design` — MISSING (breaks-gameplay) + +The client saves the in-progress shirt design as PUT multipart ('metadata' param + 'design' file part, filename file.bin); the server route only accepts POST → 405, so the designer can never save the texture. Together with the missing collection POST this makes the whole shirt-designer feature non-functional. + +Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:221` + +**Fix.** Add [HttpPut("design")] handling multipart: JSON-parse the 'metadata' form field, store the 'design' file bytes (image store / blob table) on the per-player design row, return Ok() (client return type is status-only LDGADANDBIO). + +##### `POST econ/customAvatarItems/v1/{id}/purchase/?requestedPrice={price}` — MISSING (breaks-gameplay) + +A working purchase handler exists ONLY under the api/customAvatarItems/{v1,v2}/{id:guid}/purchase prefixes; EconController registers no purchase route, so the client's POST to econ/customAvatarItems/v1/{id}/purchase/?requestedPrice=N 404s — buying a custom shirt is impossible. Secondary risk once routed: the api-side handler returns {Success, Balance, Item} / {Success, AlreadyOwned, Item} while the client's result DTO JEFHGGIDKEG keys are UNKNOWN — verify the first live purchase response parse. + +Handler: `none (api-side sibling: DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:265)` + +**Fix.** Add [HttpPost("/econ/customAvatarItems/v1/{id:guid}/purchase")] (rooted route, trailing-slash-tolerant) on CustomAvatarItemsController delegating to the existing Purchase(Guid) action; also read the requestedPrice query param and reject/adjust when it differs from item.Price to keep price-display honesty. + +##### `POST api/avatar/v2/gifts/generate` — SHAPE_MISMATCH (degraded) + +Route and verb exist (v2+v3), response ToWire is correct, and GenerateGiftRequest properties match the client's exact body keys (GiftContext/IsGameGift/AlternateGiftContext/Message). BUT the handler binds [FromBody] GenerateGiftRequest? (line 68), which only the JSON input formatter can read. The client builds this request with the same BNDIAONDFFF.AFGEDDANEKP named-param helper as gifts/consume, and the server's own comment on the consume handler (lines 170-184) documents that this helper produced a form-urlencoded body live and that [FromBody] returned HTTP 415 there. The [Consumes(...form-urlencoded...)] attribute on line 67 does NOT add a form formatter — a form-encoded generate body still 415s and the reward chest never spawns a gift; a JSON body works. PLAUSIBLE (not live-confirmed which encoding generate uses; consume is proven form-encoded, so generate likely is too). + +Handler: `DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs:65` + +**Fix.** In AvatarGiftsController.Generate, drop [FromBody] and read the body the way ConsumeViaBody does: if Request.HasFormContentType read GiftContext/IsGameGift/AlternateGiftContext/Message from the form, else JSON-parse the raw body (case-insensitive), else fall back to query. Keep the response unchanged. + +##### `POST api/avatar/v3/gifts/generate` — SHAPE_MISMATCH (degraded) + +Same handler as v2 generate ([HttpPost("api/avatar/v3/gifts/generate")] on line 66), so the route/verb the 2023 client uses for reward chests exists — but it shares the exact [FromBody]-vs-form-body risk described for v2 generate (415 on a form-urlencoded body despite the Consumes attribute). + +Handler: `DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs:66` + +**Fix.** Same fix as v2 generate (manual form/JSON/query body reading); one change covers both routes. + +##### `GET api/avatar/v3/saved` — SHAPE_MISMATCH (degraded) + +Route/verb OK (v2/v3/v4) and the array element carries Slot/PreviewImageName/OutfitSelections/FaceFeatures/SkinColor/HairColor with exact PascalCase. But SavedOutfitDto (lines 95-103) has NO OutfitSelectionsV2 and NO CustomAvatarItems, while the client outfit DTO BEPMJICDNDD holds Int32 + SIX strings + List. The 2023 avatar model round-trips OutfitSelectionsV2 + CustomAvatarItems everywhere else (AvatarV2Dto has both), so a saved outfit restores without its V2 selections and without custom shirts — silent data loss on every save slot, no crash (Newtonsoft defaults the missing fields). + +Handler: `DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs:29` + +**Fix.** Add [JsonPropertyName("OutfitSelectionsV2")] string and [JsonPropertyName("CustomAvatarItems")] List to SavedOutfitDto (reuse CustomAvatarItemRefDto from AvatarV2Controller). Existing persisted JSON deserializes fine (missing props default). + +##### `POST api/avatar/v3/saved/set` — SHAPE_MISMATCH (degraded) + +Route/verb OK, [FromBody] binds the client's JSON body, upsert-by-Slot works and echoes the DTO. Same defect as the GET: [FromBody] SavedOutfitDto silently DROPS the client's OutfitSelectionsV2 string and CustomAvatarItems list at bind time (System.Text.Json ignores unknown members), so those parts of the outfit are never persisted. Also note SavedOutfitDto's non-nullable value binding is fine here (all strings + int). + +Handler: `DorkNet.Server/Controllers/API/Avatar/V3/AvatarSavedOutfitsController.cs:42` + +**Fix.** Same DTO extension as the GET; no handler change needed. + +##### `GET api/consumables/v1/getTransferable/{playerId}` — MISSING (degraded) + +No handler anywhere in the server registers getTransferable (grep over all Controllers confirms; InventoryController has only getUnlocked/consume/transfer/updateActive). The client's DNNDEMBIDKJ.PJJFBMAICLN GET String.Format("{0}v1/getTransferable/{1}") therefore 404s, and the gifting/transfer UI cannot list which consumables can be sent to the target player. + +**Fix.** Add [HttpGet("api/consumables/v1/getTransferable/{playerId:long}")] to InventoryController returning the caller's transferable consumables in the SAME BulkConsumable group shape as GetUnlockedConsumablesV2 (List client-side), with IsTransferable=true; the path id is the prospective recipient (use it for any recipient-side filtering or ignore). + +##### `POST api/consumables/v1/transfer` — SHAPE_MISMATCH (degraded) + +Route/verb exist but the handler binds [FromForm] id/recipientPlayerId/quantity (lines 214-216) with only a query-string fallback and never reads a JSON body. The client serializes TransferConsumableRequest via JsonUtility.ToJson and sends it as the request body — with [ApiController]+[FromForm], an application/json request is rejected 415 before the action runs (and even if it ran, the JSON values would never populate the form params → 400 invalid_transfer). Sending a consumable to another player always fails ('Failed to TransferConsumable'). + +Handler: `DorkNet.Server/Controllers/API/Inventory/InventoryController.cs:212` + +**Fix.** Rework TransferConsumable to read the body manually (same pattern as AvatarGiftsController.ConsumeViaBody): if form content read form keys, else JSON-parse the raw body case-insensitively accepting the plausible JsonUtility field names (id/Id, recipientPlayerId/RecipientPlayerId/destinationPlayerId, quantity/Quantity/count/Count — exact client field names are UNKNOWN from ISIL, so log unmatched bodies via request tracing to capture the real keys on first live use), keep the query fallback. + +##### `POST api/equipment/v1/update` — SHAPE_MISMATCH (degraded) + +Route/verb exist, but the handler binds [FromBody] EquipmentUpdateRequest — a SINGLE object — while the client ISIL shows it serializing a List via JsonConvert, i.e. a JSON ARRAY body. System.Text.Json cannot bind an array into an object → model binding fails → automatic 400 → the favorite/equip change never persists (client shows 'Failed to upload equipment slot updates'). PLAUSIBLE rather than CONFIRMED: the server code comment (lines 69-73) describes live-observed behavior ('only Favorited was read... the skin never stuck') that implies some client body DID bind as a single object, which contradicts the array reading — one of the two observations is wrong, so verify with request tracing. + +Handler: `DorkNet.Server/Controllers/API/Inventory/InventoryController.cs:81` + +**Fix.** Bind the body as JsonElement (or read raw), accept BOTH a single object and an array of {PrefabName, ModificationGuid, Favorited, Equipped?, IsEquipped?}, and apply the existing per-item update loop to each element. + +##### `PUT api/customAvatarItems/v1/{id}` — MISSING (degraded) + +Only [HttpGet("{id:guid}")] exists on the id route — the client's PUT (JSON body with changed name/description/price/publish-state) 405s, so editing an existing custom shirt's metadata/price is impossible. + +Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:50` + +**Fix.** Add [HttpPut("{id:guid}")] to CustomAvatarItemsController: load by PublicId, enforce CreatorPlayerId == caller, apply the DesignRequest-style optional fields from a leniently-parsed JSON body, return ToWire(item). + +##### `DELETE api/customAvatarItems/v1/{id}` — MISSING (degraded) + +No [HttpDelete] on the id route — deleting one's own custom item 405s (client is fire-and-forget, so the item just silently persists). + +Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:50` + +**Fix.** Add [HttpDelete("{id:guid}")]: creator-only, soft-delete (add an IsDeleted flag or set IsPublic=false and remove ownership rows), return Ok(). + +##### `GET econ/customAvatarItems/v1/itemOwnershipLimit` — SHAPE_MISMATCH (degraded) + +Handler returns the JSON OBJECT {"Limit":1000,"ItemOwnershipLimit":1000}, but the client method signature is FGLDKEJLAKB — it deserializes the response body as a BARE integer. Deserializing an object into Int32 throws, so the ownership-cap check preceding a custom-shirt purchase errors out. CONFIRMED from both sides (server code + client method signature). + +Handler: `DorkNet.Server/Controllers/Econ/EconController.cs:101` + +**Fix.** Change CustomAvatarItemOwnershipLimit to return Content("1000", "application/json") — the same bare-scalar pattern the server already uses for isCreationEnabled. + +##### `GET api/customAvatarItems/v2/fromCreator/{creatorId}` — SHAPE_MISMATCH (degraded) + +Route+verb exist (fromCreator/{creatorId:long} under the v2 class prefix), but the handler returns a BARE JSON array (Ok(rows.Select(ToWire)) at :131) while the client deserializes the paged container CKFLFJCNEPH {Results, TotalResults}. Parsing an array into that object throws / yields empty — viewing another player's creations shows nothing. + +Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:114` + +**Fix.** Wrap in the paged object like Owned(): return Ok(new { Results = rows.Select(ToWire), TotalResults = total }) with a CountAsync before Skip/Take. + +##### `POST api/customAvatarItems/v1/{id}/report` — MISSING (degraded) + +No report route exists on CustomAvatarItemsController (grep for '/report' finds only clubreporting/bugreporting/inventions/playerevents/PlayerReporting) — reporting a custom shirt 404s. + +**Fix.** Add [HttpPost("{id:guid}/report")] that leniently parses the JSON body (reason enum + free text; exact client keys UNKNOWN — log the first live body), stores a moderation row (reuse the PlayerReporting entity or a new CustomAvatarItemReport table), returns Ok(). + +##### `GET api/customAvatarItems/v1/design` — MISSING (degraded) + +Only [HttpPost("design")] exists; the client's GET (load saved in-progress design, response NCMMAGDIBND {Int32, Int32?, String, String}) 405s → 'Unable to get custom avatar design info' every time the designer opens. Note the existing POST design action is also semantically different from the client's contract (it creates/updates a published item from flat fields; the client never POSTs design). + +Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:221` + +**Fix.** Add [HttpGet("design")] returning the caller's saved design record. Response key names for NCMMAGDIBND are UNKNOWN from ISIL — ship a dual-cased dictionary hedging the 4 props (e.g. BaseAvatarItemId/ItemType ints + Color/ImageName strings) and capture the real names from client behavior; requires a per-player design storage row (new entity). + +##### `DELETE api/customAvatarItems/v1/design` — MISSING (degraded) + +No [HttpDelete("design")] — discarding the saved shirt design 405s ('Unable to delete custom avatar item design'). + +Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:221` + +**Fix.** Add [HttpDelete("design")] that removes the caller's design row and returns Ok(). + +##### `GET api/customAvatarItems/v1/isCreationAllowedForAccount` — SHAPE_MISMATCH (degraded) + +Handler returns the OBJECT {IsCreationAllowed, Allowed}, but the client method returns FGLDKEJLAKB — it parses the body as a BARE JSON boolean. Object-into-Boolean deserialization fails, so the can-create gate errors (likely read as not-allowed) and shirt creation is blocked at the UI gate. CONFIRMED from both sides. The two sibling flags on lines 209-215 already do it correctly with Content("true","application/json"). + +Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:194` + +**Fix.** Return Content(allowed ? "true" : "false", "application/json") from IsCreationAllowedForAccount. + +##### `GET api/ugcPurchasables/v1/items/room/{roomId}` — MISSING (degraded) + +The server only registers GET api/ugcPurchasables with a ?roomId= query (plus /update, /delete, /v1/items/bulk). The client's path form api/ugcPurchasables/v1/items/room/{roomId} matches nothing → 404 → a room's in-room UGC store list never loads on room join. The response shape itself (ToWire at :323-341) already matches the documented wire, so only the route is missing. + +Handler: `DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs:14` + +**Fix.** Add [HttpGet("api/ugcPurchasables/v1/items/room/{roomId:long}")] mapped to the existing List logic (roomId from route instead of query). + +##### `GET api/customAvatarItems/v1/minPriceForPublicItem` — UNKNOWN (none) + +Route/verb OK; server hedges with {MinPrice:100, Price:100}. Client DTO IHLMDLKOLNB is an object with a single Int32 property whose JSON key is not recoverable from ISIL (reflection DTO) — whether either hedge key matches is UNKNOWN. If neither matches, the client silently falls back to the DTO's static default rather than crashing (int defaults to 0 under Newtonsoft missing-key semantics), so worst case is a wrong minimum-price validation, not a crash. + +Handler: `DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs:217` + +**Fix.** None provable. If live testing shows the min-price gate misbehaving, dump the real key from a live Newtonsoft trace or the metadata name in the decompile and align. + +### Clubs, threads, chat and comments + +`social-clubs-chat` + +Of 65 real client routes: ~17 fully work. 20 routes are entirely MISSING (all club-chat, room-comments CRUD, member/banned/requests lists+search, hasDisabledClubChat, clubChatEnabled, minlevel, permissions PUT, club DELETE, home-clubhouse PUT/DELETE, image DELETEs, thread favorite/moderate). 13 club write endpoints exist but bind [FromBody] JSON while the 2023 client always sends application/x-www-form-urlencoded (BNDIAONDFFF transport), so every club member-management action 415s. One systemic response-shape defect: the club details envelope (returned by details/create/clubhouse/modify/mainimage/additionalimage) sends int bitmasks for CoownerPermissions/ModeratorPermissions/MemberPermissions where the 2023 MMOCDPPONNG DTO is an object — Json.NET throws, so the club page and every club-settings save fails to parse even when the mutation succeeds. Two logic bugs: clubhouse PUT reads roomId from query so the form-posted roomId silently CLEARS the clubhouse, and requesttojoin inverts the Joinability enum (AskToJoin=2 is Forbidden, InviteOnly=1 is accepted as pending). Verified against client ISIL: comments/unreadcounts wire shape is a JSON ARRAY of UnreadRoomComments (OPELMBNJHNO.txt:856, Func,Dictionary> is a client-side projection) — the server's array response is correct and the task inventory's 'JSON object map' claim is wrong; it is however a zero-count stub. members/bulk guaranteed-400s because the client only sends repeated 'id' params and never clubId. + +**Client-side notes.** TRANSPORT (applies to every endpoint here): the shared request builder BNDIAONDFFF (C:\tmp\recroom-2023-03-21-isil\IsilDump\RecNet.Runtime\BNDIAONDFFF.txt; decompile C:\tmp\recnet-runtime-decomp\BNDIAONDFFF.cs) is constructed as ctor(BestHTTP.HTTPMethods verb, GJDLNNLKDIJ serviceHost, string route). Verb ints observed: 0=GET, 2=POST, 3=PUT, 4=DELETE (BestHTTP enum). Hosts: 13=Clubs (all club/* + members/bulk), 8=Chat (thread/*), 12=RoomComments (comments/*), 3=Matchmaking (clubhousesearch) — enum in recnet-runtime-decomp/GJDLNNLKDIJ.cs. CRITICAL: parameters added via AFGEDDANEKP become URL query string ONLY for GET; for POST/PUT/DELETE they are sent as an application/x-www-form-urlencoded body (HTTPUrlEncodedForm/HTTPFormBase.AddField, or multipart when files present) — see FGHNOKLDOKO branch at BNDIAONDFFF.txt:2900-3260. The 2023 client NEVER sends JSON bodies on this path; every DorkNet handler that binds [FromBody] JSON for these routes (club member ops ClubsController.cs:503-677, snooze/rename in ChatController) will 415/400 for this client. ChatController already has form-reading twins for thread/withmembers, thread, thread/{id} (ChatController.cs:438-528) — the same treatment is needed for the club write endpoints. Clubs write helpers: IKMMOCKDKAF.IBAKMFKEEDJ(desc, verb, route, Action) parses an LCLFBBPEMIH details envelope; JNFEEDLCGHH is the status-only variant (no DTO parse). SERVER GAPS (2023 client will 404/405/415): missing routes — club/{id} DELETE, club/home/me PUT+DELETE, club/{id}/mainimage DELETE, club/{id}/additionalimage/{slot} DELETE, club/{id}/clubChatEnabled PUT, club/{id}/hasDisabledClubChat GET, club/{id}/minlevel PUT, club/{id}/members GET, club/{id}/members/banned GET, club/{id}/members/requests GET, club/{id}/members/requests/search GET, club/{id}/members/search GET, club/{id}/permissions/{role} PUT, thread/club/{clubId} GET, thread/message/{id}/moderate PUT, thread/{id}/favorite PUT, comments/get/{roomId} GET, comments/create/{roomId} POST, comments/delete/{id} DELETE, comments/read/{roomId}/{id} PUT. Verb mismatches — thread/{id}/leave (client POST, server DELETE only), thread/{id}/rename (client POST form, server PUT JSON). Param mismatch — members/bulk: client sends only repeated 'id' query params (clubId never transmitted; it's only the client cache key), server demands clubId+playerIds → always 400. Shape risk — the three *Permissions keys in the club details envelope: server sends int bitmasks (ClubsController.cs:806-809) but the 2023 MMOCDPPONNG DTO is an object {ClubId:long, MembershipType:int, approveMember/banUnban/createEvent/editDetails/editPermissionSettings/postAnnouncement:bool} (keys per the PUT permissions closure; response casing unverified). RESPONSE KEY CASING caveat: 2023 DTO JSON names live in DataMember attribute blobs in global-metadata.dat and are NOT visible in the ISIL or decompile dumps; where I state keys they come from DorkNet's live-validated projections (ClubsController/ChatController) whose casing the 2023 client demonstrably accepts for the already-working endpoints — chat is camelCase (chatThreadId, chatMessageId, senderPlayerId, timeSent, contents, chatResult), clubs are PascalCase (ClubId, MemberCount, MembershipType, Clubs/TotalClubs/ContinuationToken), consistent with the per-DTO-casing memory note. MessageJson field names (Type/Version/Data) are UNOBFUSCATED in the binary and are the exact contents-string schema. Enum wire values worth persisting: MJOOBDNCHBO membership (Banned=-1, None=0, Pending_Requested=1, Pending_Invited=2, Pending_Denied=3, Member=10, Moderator=20, CoOwner=30, Creator=100 — matches ClubService constants memory), club State IACDINKNHKB (Active=0, PendingJunior=11, Moderation_* 100/101, MarkedForDelete=1000), message moderation MOFGOGFEHNN (0/11/100/101/102). Non-route literal: club_events/{0} is a player-events cache key (EMMAMFINMMJ/ENKNLDABNBB string builders), not HTTP; club/account/{0}/created, club/mine/created, club/mine/member, club/home/me double as client cache keys in IKMMOCKDKAF (GMCDPOAJOID:10948, AJDNEGBEKNI:30123-30328) but are also real GET routes. + +#### Endpoints + +| Verb | Route | Request | Response | +|---|---|---|---| +| GET | `club/account/{0}/created` | none (accountId in path) | JSON array of Club objects. Club CLR layout (recnet-runtime-decomp/FOIJDINBPFG.cs): ObscuredLong ClubId, string Name, string Description, string MainImageName, IACDINKNHKB State (int: Active=0,PendingJunior=11,Moderation | +| GET | `club/categoryTags` | none | JSON array of strings (category tag names) | +| POST | `club/create` | application/x-www-form-urlencoded: name (string), description (string), category (string). NOT JSON — BNDIAONDFFF.FGHNOKLDOKO puts AFGEDDANEKP params into HTTPUrlEncodedForm for no | LCLFBBPEMIH envelope (recnet-runtime-decomp/LCLFBBPEMIH.cs): Club (FOIJDINBPFG), CustomTags (List), AdditionalImages (List{string imageName,int slot}), 3x role-permission objects (MMOCDPPONNG), MyMem | +| GET | `club/home/me` | none | single Club object (see club/account entry) | +| PUT | `club/home/me` | form-urlencoded: clubId (Int64) | status-only (JNFEEDLCGHH parses no DTO — IKMMOCKDKAF.txt:26503-26612) | +| DELETE | `club/home/me` | none | status-only | +| GET | `club/mine/created` | none | JSON array of Club | +| GET | `club/mine/member` | none | JSON array of Club | +| GET | `club/mostactivetoday` | none | JSON array of Club (bare list, not paged envelope) | +| GET | `club/search` | query: sort (int; MJLECOMCJCN: MemberCount_Desc=0,NewestFirst=1,OldestFirst=2,MostRecentlyUpdatedRoom=3,MostRecentlyUpdatedInvention=4), query (string), category (string), count (i | paged envelope EEMELNLCJJF: List + Int32 total + String continuationToken. Server's validated keys: Clubs, TotalClubs, ContinuationToken (ClubsController.cs:100-106) | +| GET | `club/{0}` | none | single Club object | +| DELETE | `club/{0}` | none (name arg is client-side only) | status-only | +| PUT | `club/{0}/additionalimage/{1}` | form-urlencoded: imageName (string) | LCLFBBPEMIH details envelope | +| DELETE | `club/{0}/additionalimage/{1}` | none | LCLFBBPEMIH details envelope | +| PUT | `club/{0}/clubChatEnabled` | form-urlencoded: clubChatEnabled (bool) | LCLFBBPEMIH details envelope | +| PUT | `club/{0}/clubhouse` | form-urlencoded: roomId (Int64). Server binds [FromQuery(Name="roomId")] (ClubsController.cs:271) — will miss a form-encoded roomId on PUT | LCLFBBPEMIH details envelope | +| DELETE | `club/{0}/clubhouse` | none | LCLFBBPEMIH details envelope | +| GET | `club/{0}/details` | none | LCLFBBPEMIH envelope (see club/create). MMOCDPPONNG permission object CLR layout: long ClubId, MJOOBDNCHBO MembershipType, 6 bools whose request-side key names are approveMember/banUnban/createEvent/editDetails/editPermi | +| GET | `club/{0}/hasDisabledClubChat` | none | bare JSON boolean (FGLDKEJLAKB) | +| PUT | `club/{0}/mainimage` | form-urlencoded: imageName (string) | LCLFBBPEMIH details envelope | +| DELETE | `club/{0}/mainimage` | none | LCLFBBPEMIH details envelope | +| GET | `club/{0}/members` | query: membershipType (int; Banned=-1,None=0,Pending_Requested=1,Pending_Invited=2,Pending_Denied=3,Member=10,Moderator=20,CoOwner=30,Creator=100), sortBy (int; Default=0,JoinDate_ | JSON array of member rows. CADEIMCFIIG CLR: int AccountId, long ClubId, MJOOBDNCHBO MembershipType, DateTime CreatedAt (keys per server's validated ToWireMembership, ClubsController.cs:836-842). MISSING on server (only / | +| POST | `club/{0}/members/acceptinvite` | none | status-only | +| POST | `club/{0}/members/acceptrequest` | form-urlencoded: accountId (Int32). Server binds [FromBody] JSON (ClubsController.cs:571) — content-type mismatch for this client | status-only | +| POST | `club/{0}/members/acceptrequests` | form-urlencoded: accountIds (repeated Int32 list) | status-only | +| POST | `club/{0}/members/ban` | form-urlencoded: accountId (Int32) | status-only | +| GET | `club/{0}/members/banned` | query: sortBy, skip, take | JSON array; ICPNBOOIDLI CLR: int AccountId, long ClubId, DateTime CreatedAt (banned-at). MISSING on server | +| PUT | `club/{0}/members/changetype` | form-urlencoded: accountId (Int32), membershipType (int enum) | status-only | +| POST | `club/{0}/members/declineinvite` | none | status-only | +| POST | `club/{0}/members/denyrequest` | form-urlencoded: accountId (Int32) | status-only | +| POST | `club/{0}/members/denyrequests` | form-urlencoded: accountIds (repeated list) | status-only | +| POST | `club/{0}/members/directJoin` | form-urlencoded: inviterAccountId (Int32), joinability (int; Open=0,InviteOnly=1,AskToJoin=2). Client short-circuits with toast "You're already a member of this club!" (IKMMOCKDKAF | status-only | +| PUT | `club/{0}/members/invite` | form-urlencoded: accountId (Int32), membershipType (int enum) | status-only | +| POST | `club/{0}/members/invitemembers` | form-urlencoded: accountIds (repeated list), bulkInviteType (int; Nearby=0,Friends=1) | status-only | +| POST | `club/{0}/members/leave` | none | status-only | +| POST | `club/{0}/members/remove` | form-urlencoded: accountId (Int32) | status-only | +| GET | `club/{0}/members/requests` | query: skip, take | JSON array; AADIHDCMEDB CLR: long (request id), int AccountId, int? (inviter account id), long ClubId, MJOOBDNCHBO MembershipType, MDFFODMAIGJ status (Invited=0,Requested=1,Denied=2), DateTime CreatedAt. Response key cas | +| GET | `club/{0}/members/requests/search` | query: parameters.name, parameters.sortBy (Default=0,RequestDate_Asc=1,RequestDate_Desc=2,Username_Asc=3,Username_Desc=4), parameters.maxCount, parameters.status (Invited=0,Request | paged envelope EDFOCLNECPM: List + Int32 total + String continuationToken (key casing UNKNOWN). MISSING on server | +| PUT | `club/{0}/members/requesttojoin` | none | status-only | +| GET | `club/{0}/members/search` | query: parameters.name, parameters.type (membership enum int), parameters.sortBy, parameters.maxCount, continuationToken (unprefixed) | paged envelope MBMNHFAPFCJ: List + Int32 total + String continuationToken (key casing UNKNOWN). MISSING on server | +| POST | `club/{0}/members/unban` | form-urlencoded: accountId (Int32) | status-only | +| GET | `club/{0}/members/{1}` | none | single membership row: AccountId (int), ClubId (long), MembershipType (int), CreatedAt (DateTime) — server-validated keys ClubsController.cs:836-842 | +| PUT | `club/{0}/minlevel` | form-urlencoded: minLevel (Int32) | LCLFBBPEMIH details envelope | +| PUT | `club/{0}/modify` | form-urlencoded: name, description, category (strings) | LCLFBBPEMIH details envelope | +| PUT | `club/{0}/modifydetails` | form-urlencoded: allowJuniors (bool), customTags (list), joinability (int), visibility (int; Private=0,Public=1) | LCLFBBPEMIH details envelope | +| PUT | `club/{0}/permissions/{1}` | form-urlencoded: approveMember, banUnban, createEvent, editDetails, editPermissionSettings, postAnnouncement (6 bools) | LCLFBBPEMIH details envelope | +| GET | `clubhousesearch/mostactivenow` | none | JSON array; OEJFMLMJINJ CLR: long (RoomId — same field name EEFNKAFLPLG as RoomComment.RoomId), long ClubId (field AAGOEEGKGJL, the ClubId name across club DTOs), int (player/active count, key UNKNOWN). Sent to Matchmaki | +| POST | `comments/create/{0}` | form-urlencoded: message (string), subRoomId (Int64), style (int; Feedback=0,Idea=1), positionX, positionY, positionZ (floats). RoomComments host (r8=12) | single RoomComment. CLR (recnet-runtime-decomp/RecNet/RoomComment.cs): long CommentId, long RoomId, long? SubRoomId, int? AccountId, DateTime CreatedAt, Vector3? position, string message, EKHGDABMJOJ style, 2 bools. JSON | +| DELETE | `comments/delete/{0}` | none | status-only ('Failed to delete comment'). MISSING on server | +| GET | `comments/get/{0}` | query: count (Int32), minId (Int64) | JSON array of RoomComment. MISSING on server | +| PUT | `comments/read/{0}/{1}` | none | status-only ('Failed to update latest read comment'). MISSING on server | +| GET | `comments/unreadcounts` | roomIds (repeated Int64 list). Verb is dynamic: GET when <100 room ids (query), POST with form body when >=100 (verb = count<100 ? 0 : 2 — cmovl at instr 059-061). Server registers | JSON object mapping roomId (stringified Int64 key) -> unread count (UInt32) | +| GET | `members/bulk` | query: id (repeated, one per account). clubId is NOT transmitted — it is only the client-side cache key (response handler NJNJEOFNJIG(clubId, List) at IKMMOCKDKAF.txt: | JSON array; FCKGOFHNDNJ CLR: int AccountId, MJOOBDNCHBO MembershipType | +| GET | `thread/club/{0}` | query: maxCount (int, default 10), mode (int, semantics UNKNOWN — client always sends 0). Chat host (GJDLNNLKDIJ.Chat=8) | JSON array of chat threads. The thread reader (EKEFFNBIOHJ.txt instrs 082/109/133/149/173/197/221/245/269) takes exactly nine keys in three casings: ChatThreadId, LastReadMessageId, Messages, LatestMessage, PlayerIds, ChatThreadName, SnoozedUntil, IsFavorited, ClubId. IMPLEMENTED: `ChatController.GetClubThreads` — one `club:{clubId}` thread per club, roster-gated, returned as a one-element array | +| PUT | `thread/message/{0}/moderate` | form-urlencoded: moderationState (byte enum: Active=0,Junior_Pending=11,Moderation_Pending=100,Moderation_Closed=101,Moderation_Banned=102) | chat-result enum MGGNHLHPHOF (Success=0,InvalidArguments,ThreadNotFound,MembershipNotFound,PlayerAlreadyOnThread,CannotMessagePlayer,InvalidCharacters,RecentlyLeftThread,ThreadTooLarge) — bare value (`Action` at DLDKCILCKNA.txt instr 101). STILL MISSING on server — blocked on a `ChatMessageEntity.ModerationState` column | +| POST | `thread/withmembers` | form-urlencoded: ids (repeated Int32 list), messageCount (int). Server has a form-reading variant (ChatController.cs:438-446) — matches | single thread object (get-or-create DM/group with these members) | +| GET | `thread/{0}` | query: messageCount (int) | single thread object with messages | +| POST | `thread/{0}` | form-urlencoded: messageContents = JSON string of DLDKCILCKNA.MessageJson with PRESERVED field names {Type (int: Text=0,PartyInvite=1), Version (int), Data (string)} (recnet-runtim | ANDECIOOJNB: {ChatMessage, chat-result enum}. Server's validated keys: chatMessage, chatResult; message keys: chatMessageId, chatThreadId, senderPlayerId, timeSent, contents (ChatController.cs:792-810) | +| PUT | `thread/{0}/favorite` | form-urlencoded: favorite (bool) | NOT a bare enum — the continuation is `Func` (DLDKCILCKNA.txt instr 142), so the body is the wrapper object GLIKNMPPEHL, read by OGCMIDEFOEE.txt (instrs 042/069) with exactly two keys in three casings: ChatResult, IsFavorited. IMPLEMENTED: `ChatController.Favorite` — flag persisted in PlayerSettings under `chat.favorite:` and echoed back in the thread DTO's IsFavorited | +| POST | `thread/{0}/leave` | none | chat-result enum. VERB MISMATCH: server registers HttpDelete only (ChatController.cs:337) → 405 for this client's POST | +| POST | `thread/{0}/member/{1}` | none (both ids in path) | chat-result enum (PlayerAlreadyOnThread etc.). Server POST exists (ChatController.cs:673) | +| GET | `thread/{0}/message` | query: messageCount (int), mode (int, semantics UNKNOWN), referenceMessageId (Int64) | JSON array of ChatMessage. CLR (recnet-runtime-decomp/RecNet/ChatMessage.cs): long MessageId, long ThreadId, int SenderAccountId, DateTime SentAt, string contents, MOFGOGFEHNN moderation state, MessageJson parsed content | +| POST | `thread/{0}/message/{1}/read` | none | chat-result enum. Server accepts POST+PUT (ChatController.cs:268-269) — OK | +| POST | `thread/{0}/rename` | form-urlencoded: name (string) | chat-result enum, bare value (`Action` at DLDKCILCKNA.txt instr 162). FIXED: `ChatController.Rename` now registers PUT+POST and binds via FormOrJsonModelBinder | +| POST | `thread/{0}/snooze` | form-urlencoded: snooze (bool) | chat-result enum. ENCODING MISMATCH: server POST exists but binds [FromBody] SnoozeRequest JSON (ChatController.cs:307-308) → 415 on form content-type | + +#### Not HTTP routes + +These literals look like paths but are cache keys, deeplinks or MIME types: + +- `club_events/{0}` — Player-events cache layer, keyed per club + +#### Defects + +##### `POST club/create` — SHAPE_MISMATCH (breaks-gameplay) + +Request side is fine (ReadCreateClubRequestAsync at :888-912 reads form keys name/description/category). But the response is BuildDetailsResponseAsync (:794-808) whose CoownerPermissions/ModeratorPermissions/MemberPermissions are int bitmasks (PermissionsForRole, :819-826) while the 2023 LCLFBBPEMIH DTO declares three MMOCDPPONNG OBJECT fields (long ClubId, MJOOBDNCHBO MembershipType, 6 bools — verified C:/tmp/recnet-runtime-decomp/LCLFBBPEMIH.cs:64-115, MMOCDPPONNG.cs:16-157). Json.NET deserializing an integer into a complex object throws, so club creation appears to fail client-side even though the club row is created. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:338` + +**Fix.** In BuildDetailsResponseAsync emit each *Permissions key as an object {ClubId, MembershipType, approveMember, banUnban, createEvent, editDetails, editPermissionSettings, postAnnouncement} (key casing of the 6 bools per the PUT permissions request closure; response casing UNKNOWN — mirror request casing and validate live). Applies to all 8 envelope-returning handlers at once. + +##### `PUT club/home/me` — FIXED + +`ClubsController.HomeMeSet` reads the form-urlencoded `clubId` (query fallback) and stores it through `ClubService.SetHomeClubAsync`, which persists into the general-purpose `PlayerSettings` key/value bag under key `club.home` — no new column needed. Returns an empty 200; the client's `LDGADANDBIO JOPMBFIFFBB(Nullable)` parses no DTO. `HomeClubAsync` now prefers the stored pick and only falls back to most-recently-joined when nothing is stored or the stored club was disbanded. + +##### `DELETE club/home/me` — FIXED + +`ClubsController.HomeMeClear` removes the `club.home` `PlayerSettings` row (idempotent) and returns an empty 200. + +##### `DELETE club/{0}` — FIXED + +`ClubsController.ClubDelete` → `ClubService.DisbandAsync`: owner-only (co-owners/moderators are rejected, unlike `ModifyAsync`), soft delete stamping `State = 1000` (`IACDINKNHKB.MarkedForDelete`). Every read path here filters `State == 0`, so the club disappears from browse/mine/details while its announcements and membership rows survive for moderation. Returns an empty 200. + +##### `PUT club/{0}/clubChatEnabled` — MISSING (breaks-gameplay) — BLOCKED ON SCHEMA + +No route → 404. Club-chat toggle in settings fails. Verified from the binary: `MEPEAAJOGLP(Int64, Boolean)` at IKMMOCKDKAF.txt:25601, route literal :25779, verb ordinal 3 (PUT) at :25802, form key `clubChatEnabled` (IKMMOCKDKAF_NestedType_BLCIHIILJNE.txt:67), response is the `LCLFBBPEMIH` details envelope. Deliberately NOT stubbed: a handler that accepted the toggle and dropped it would make the settings screen lie, and `GET club/{0}/hasDisabledClubChat` (now served) would keep answering `false`. + +**Fix.** Needs `ClubEntity.ClubChatEnabled` (bool, default true). Once the column exists: `[HttpPut("/club/{clubId:long}/clubChatEnabled")]` reading the form `clubChatEnabled` through `FormOrJsonModelBinder`, write via `ModifyAsync`, return `BuildDetailsResponseAsync`, and update BOTH `ToWireClub`'s `ClubChatEnabled = true` literal and `ClubsController.ClubHasDisabledChat`'s `false`. + +##### `PUT club/{0}/clubhouse` — SHAPE_MISMATCH (breaks-gameplay) + +Route+verb exist, but the handler binds [FromQuery(Name="roomId")] (:271) while the 2023 client sends roomId in the form-urlencoded BODY. roomId binds null, and the PUT branch then executes c.ClubhouseRoomId = null (:277-278) — setting a clubhouse silently CLEARS it instead. Response envelope also hits the *Permissions int defect. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:269` + +**Fix.** In ClubClubhouse, when Request.HasFormContentType also parse Request.Form["roomId"] and prefer it over the query value before mutating. + +##### `GET club/{0}/details` — SHAPE_MISMATCH (breaks-gameplay) + +All 7 keys present (Club, CustomTags, AdditionalImages, CoownerPermissions, ModeratorPermissions, MemberPermissions, MyMembershipType) but the three *Permissions values are ints (0x7FFE/0x00FF/0x0007 from PermissionsForRole :819-826) whereas 2023's LCLFBBPEMIH fields JNOKOILEFHD/OMMHPBFEHBH/IJKKPOBDJGG are MMOCDPPONNG OBJECTS (LCLFBBPEMIH.cs:64-115). Strict Json.NET read of int-into-object throws → the whole club page load fails. CustomTags/AdditionalImages are always empty (not persisted) — cosmetic on top. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:258` + +**Fix.** Emit MMOCDPPONNG-shaped objects for the three permission keys (see club/create finding); persist CustomTags/AdditionalImages later. + +##### `GET club/{0}/hasDisabledClubChat` — FIXED (pending schema) + +`ClubsController.ClubHasDisabledChat` 404s an unknown/disbanded club and otherwise answers a bare JSON `false` (the issuing method is `FGLDKEJLAKB OFOJAHBKMOJ(Int64)` — a bare boolean body, not an object). `false` is the *true* answer today: the value is the negation of the Club wire field `ClubChatEnabled`, which has no `ClubEntity` column and whose setter (`PUT club/{0}/clubChatEnabled`) is therefore still unimplemented, so no club can be in the disabled state. When the column lands, this handler and `ToWireClub`'s `ClubChatEnabled` literal must change together. + +##### `PUT club/{0}/mainimage` — SHAPE_MISMATCH (breaks-gameplay) + +Handler binds [FromBody] MainImageRequest (JSON) at :399; the 2023 client posts form-urlencoded imageName → 415 Unsupported Media Type before the action runs. 'Modify Club Image' always fails. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:396` + +**Fix.** Read Request.Form["imageName"] when HasFormContentType (mirror ReadCreateClubRequestAsync pattern), keep JSON path for other callers. + +##### `GET club/{0}/members` — FIXED + +`ClubsController.ClubMembers` binds `membershipType`/`sortBy`/`skip`/`take` and returns the bare `List` array via the existing `ToWireMembership` projection. `membershipType` is compared as the MJOOBDNCHBO wire value (after `MembershipTypeFromPerms`), not the stored perms int; with no filter the roster is real members only, so ban markers (256) and pending rows (128) stay in their own endpoints. `sortBy` implements GNLOJEONFIG (Default=privileged-first, JoinDate_Asc/Desc, Username_Asc/Desc) — usernames are joined in from `Players` once per request. + +##### `POST club/{0}/members/acceptrequest` — SHAPE_MISMATCH (breaks-gameplay) + +[FromBody] MemberTargetRequest JSON binding (:571) vs client's form-urlencoded accountId → 415. Approving a join request always fails. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:569` + +**Fix.** Add form support: when Request.HasFormContentType read accountId from Request.Form (a shared helper for all member ops); property name AccountId already aligns. + +##### `POST club/{0}/members/acceptrequests` — SHAPE_MISMATCH (breaks-gameplay) + +[FromBody] BulkTargetRequest JSON vs client's repeated form accountIds → 415. 'Approve all' fails. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:589` + +**Fix.** Read repeated Request.Form["accountIds"] values when form content-type. + +##### `POST club/{0}/members/ban` — SHAPE_MISMATCH (breaks-gameplay) + +[FromBody] JSON vs form accountId → 415. Ban always fails. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:644` + +**Fix.** Same form-reading helper. + +##### `PUT club/{0}/members/changetype` — SHAPE_MISMATCH (breaks-gameplay) + +PUT is registered (:675) but [FromBody] JSON binding vs client's form accountId+membershipType → 415. Promote/demote always fails. (Wire enum handling via PermsFromMembershipType is otherwise correct: Member=10/Moderator=20/CoOwner=30 map to 0/24/124, ClubService.cs:366-372.) + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:674` + +**Fix.** Form-reading helper for accountId + membershipType. + +##### `POST club/{0}/members/denyrequest` — SHAPE_MISMATCH (breaks-gameplay) + +[FromBody] JSON vs form accountId → 415. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:604` + +**Fix.** Form-reading helper. + +##### `POST club/{0}/members/denyrequests` — SHAPE_MISMATCH (breaks-gameplay) + +[FromBody] JSON vs repeated form accountIds → 415. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:615` + +**Fix.** Form-reading helper. + +##### `PUT club/{0}/members/invite` — SHAPE_MISMATCH (breaks-gameplay) + +PUT registered (:504) but [FromBody] MemberInviteRequest JSON vs client's form accountId+membershipType → 415. Inviting a player always fails. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:503` + +**Fix.** Form-reading helper. + +##### `POST club/{0}/members/invitemembers` — SHAPE_MISMATCH (breaks-gameplay) + +[FromBody] InviteMembersRequest JSON vs client's repeated form accountIds + bulkInviteType → 415. Bulk invite fails. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:525` + +**Fix.** Form-reading helper (accountIds repeated; bulkInviteType may be ignored). + +##### `POST club/{0}/members/remove` — SHAPE_MISMATCH (breaks-gameplay) + +[FromBody] JSON vs form accountId → 415. Kick always fails. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:626` + +**Fix.** Form-reading helper. + +##### `PUT club/{0}/members/requesttojoin` — SHAPE_MISMATCH (breaks-gameplay) + +Verb OK (POST+PUT registered, no body needed) but the Joinability switch is inverted vs the client enum LNJLEKOPAHB (Open=0, InviteOnly=1, AskToJoin=2): server maps 1→pending and everything else→Forbid (:453-459). So the 'Ask to join' button on AskToJoin(2) clubs always gets 403, while InviteOnly(1) clubs wrongly accept join requests as pending. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:446` + +**Fix.** Change the switch: 0→instant member, 2→pending(128), 1(InviteOnly)→Forbid. Audit other Joinability comparisons in ClubService for the same inversion. + +##### `POST club/{0}/members/unban` — SHAPE_MISMATCH (breaks-gameplay) + +[FromBody] JSON vs form accountId → 415. Unban always fails. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:659` + +**Fix.** Form-reading helper. + +##### `PUT club/{0}/modify` — SHAPE_MISMATCH (breaks-gameplay) + +PUT registered but [FromBody] ModifyClubRequest JSON (:371) vs client's form name/description/category → 415. Editing club name/description/category always fails. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:366` + +**Fix.** Read form fields when HasFormContentType (extend ReadCreateClubRequestAsync pattern to modify). + +##### `PUT club/{0}/modifydetails` — SHAPE_MISMATCH (breaks-gameplay) + +Same handler as /modify: [FromBody] JSON → 415 on the client's form allowJuniors/customTags/joinability/visibility. Additionally ModifyClubRequest has no CustomTags member and tags are never persisted. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:368` + +**Fix.** Form-read allowJuniors (bool), joinability (int), visibility (int), repeated customTags; persist CustomTags so the details envelope can echo them. + +##### `GET members/bulk` — SHAPE_MISMATCH (breaks-gameplay) + +Client sends ONLY repeated 'id' query params (clubId is a client-side cache key, never transmitted). Server hard-requires a clubId param (:702-704 → 400) and reads ids from 'playerIds' (:706-708), so even with clubId it would collect zero ids. Every call is a guaranteed 400 → membership badges on nameplates never resolve. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:697` + +**Fix.** Accept repeated 'id' query params; when clubId is absent, return each account's membership without club scoping (e.g. highest/most-relevant membership per account) or infer from context — response stays [{AccountId, MembershipType}]. + +##### `GET thread/club/{0}` — FIXED (was breaks-gameplay) + +'thread/club/123' (3 segments) matched no template — thread/{chatThreadId} is 2 segments — → 404. The club chat tab could not open at all. + +**Fixed.** `ChatController.GetClubThreads` — `[HttpGet("/thread/club/{clubId:long}")]`, get-or-creates the `club:{clubId}` thread (named after the club on first materialisation), honours `maxCount`, accepts and ignores `mode`, and returns a one-element JSON ARRAY of ToWireThread objects. Non-roster callers and unknown clubs get `[]` (the client deserialises the body before it looks at the status). The thread helpers now understand `club:` keys: `LoadParticipantsAsync` resolves participants from ClubMemberships (pending 128 / banned 256 excluded), `ThreadRecipientsAsync` fans new messages out over the roster, `IsThreadMemberAsync` gates rename/favorite on roster membership, `Leave` refuses club threads, and `GetThreads` lists club channels that have messages. ToWireThread now also emits the two keys the 2023 reader wants and the 2020 DTO lacked: `isFavorited` and `clubId`. + +##### `POST thread/{0}/leave` — VERB_MISMATCH (breaks-gameplay) + +Server registers HttpDelete only; the 2023 client sends POST → 405. Leaving a group chat is impossible. + +Handler: `DorkNet.Server/Controllers/Chat/ChatController.cs:337` + +**Fix.** Add [HttpPost("/thread/{chatThreadId}/leave")] on the same action. + +##### `PUT club/{0}/additionalimage/{1}` — STUB (degraded) + +Two defects: (1) handler binds [FromBody] MainImageRequest JSON but the 2023 client sends form-urlencoded imageName → ASP.NET [ApiController] returns 415 before the handler runs; (2) even reachable, the handler is an admitted stub — it never persists the slot (comment at :411-416, 'gallery renders blank') and just re-emits current details. Response envelope also carries the *Permissions int defect. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:417` + +**Fix.** Persist additional image slots (new AdditionalImages storage keyed clubId+slot), read Request.Form["imageName"] when HasFormContentType, echo them in the envelope's AdditionalImages as [{imageName,slot}] per HIKCHBLAMLP. + +##### `DELETE club/{0}/additionalimage/{1}` — MISSING (degraded) — BLOCKED ON SCHEMA + +Only POST/PUT registered → DELETE 405. 'Remove Additional Club Image' fails. Verified from the binary: `AKJJLMLPBKN(Int64, Int32 slot, String imageName)` at IKMMOCKDKAF.txt:16054 branches on the captured imageName (:16219) — verb ordinal 4 with no body when null (description "Remove Additional Club Image", :16229-16234), verb 3 with the form field otherwise (:16256-16260) — and both branches parse the `LCLFBBPEMIH` details envelope. + +Deliberately NOT registered: the PUT twin is already an admitted stub that persists nothing, so a DELETE would have nothing to clear. Registering it would only convert a visible 405 into a silent success while the gallery stays blank. + +**Fix.** Needs the additional-image store the PUT twin also wants: a `ClubAdditionalImageEntity` keyed (ClubId, Slot) with an ImageName column. Then register `[HttpDelete("/club/{clubId:long}/additionalimage/{slot:int}")]`, delete the row, and have `BuildDetailsResponseAsync` project the rows into the envelope's `AdditionalImages` as `HIKCHBLAMLP` {imageName, slot} instead of the current `Array.Empty()`. + +##### `DELETE club/{0}/mainimage` — FIXED + +`ClubsController.ClubMainImageDelete` is its own action (NOT a third verb binding on the POST/PUT setter — aliasing an edit verb onto a destructive handler is exactly what made `PUT /announcements/club/{c}/{a}` delete posts). Clears `ImageName` through `ModifyAsync` and returns the full `LCLFBBPEMIH` details envelope, which is what `IBAKMFKEEDJ` parses on both branches of `BLEDOCHHHJM` — the DELETE branch is *not* status-only. + +##### `GET club/{0}/members/banned` — FIXED + +`ClubsController.ClubMembersBanned` returns the bare `List` array — three fields only ({AccountId:int, ClubId:long, CreatedAt}; ICPNBOOIDLI has no MembershipType) — over rows carrying the perms 256 ban marker, with `sortBy`/`skip`/`take`. Moderator-gated (`CanManageAsync`): the ban list is staff-only information. + +##### `GET club/{0}/members/requests` — FIXED + +`ClubsController.ClubMemberRequests` returns the bare `List` array over rows with the 128 pending marker (and without the 256 ban marker), `skip`/`take`, moderator-gated. Two fields are still un-fillable rather than invented: `InviterAccountId` is emitted as null (the DTO declares it nullable; invites and requests both collapse to the single 128 marker on the target's own row, so no inviter is recorded) and `Status` is always Requested for the same reason. Neither is read by the accept/deny flow, which keys off `accountId`. Persisting invited-vs-requested would let `Status` be filled properly. + +##### `GET club/{0}/members/requests/search` — FIXED + +`ClubsController.ClubMemberRequestsSearch` reads the dot-prefixed keys straight off `Request.Query` (`parameters.name`, `parameters.sortBy`, `parameters.maxCount`, `parameters.status`, plus the unprefixed `continuationToken`) and returns the `EDFOCLNECPM` paged envelope. `continuationToken` is an opaque row offset (String on the DTO); an empty string means no further pages, matching the `club/search` envelope. NAHBJCGNJKA's RequestDate_Asc/Desc index the same JoinedAt column GNLOJEONFIG's JoinDate_Asc/Desc do, so both enums share one comparator. Envelope key names remain unverified (DataMember blobs are not in the ISIL/decompile), so it emits the entity-named pair validated on the sibling `club/search` envelope — `Requests`/`TotalRequests` — alongside `Results`/`TotalResults`; Json.NET ignores whichever pair the DTO doesn't declare. + +##### `GET club/{0}/members/search` — FIXED + +`ClubsController.ClubMembersSearch`, same treatment: `parameters.name`/`parameters.type`/`parameters.sortBy`/`parameters.maxCount` + `continuationToken` off `Request.Query`, returning the `MBMNHFAPFCJ` paged envelope with `Members`/`TotalMembers` plus `Results`/`TotalResults` and `ContinuationToken`. + +##### `PUT club/{0}/minlevel` — MISSING (degraded) — BLOCKED ON SCHEMA + +No club minlevel route (only Rooms.MinLevel exists) → 404. 'Min Level to Join' setting fails. Verified from the binary: `AOAFDLMFCGB(Int64, Int32)` at IKMMOCKDKAF.txt:16448, route literal :16561, verb ordinal 3 (PUT) at :16584, form key `minLevel` (IKMMOCKDKAF_NestedType_AOOCOABJPDN.txt:67), response is the `LCLFBBPEMIH` details envelope via IBAKMFKEEDJ. + +**Fix.** Needs `ClubEntity.MinLevel` (int, default 0). Once the column exists: `[HttpPut("/club/{clubId:long}/minlevel")]` reading the form `minLevel` through `FormOrJsonModelBinder`, write via `ModifyAsync`, return `BuildDetailsResponseAsync`, and swap `ToWireClub`'s `MinLevel = 0` literal for the column. + +##### `PUT club/{0}/permissions/{1}` — MISSING (degraded) + +Only HttpGet is registered → PUT 405. Per-role permission checklist saves fail. BLOCKED ON SCHEMA. Verified from the binary: `HDDNEPNKCIK(MMOCDPPONNG)` at IKMMOCKDKAF.txt:17537 formats the route from the DTO's own ClubId + MembershipType (:17671-17689), verb ordinal 3 (PUT) at :17709, description "Modify Club Permissions", response is the `LCLFBBPEMIH` details envelope. Form keys are the 6 bools `editDetails`/`approveMember`/`createEvent`/`postAnnouncement`/`editPermissionSettings`/`banUnban` (IKMMOCKDKAF_NestedType_BOIMHOCCOEI.txt:173-253), matching the response-side reader BPHCOIBNCDP.txt:638-758. + +Deliberately NOT stubbed: `PermissionsForRole` derives the six bools from the role with a fixed default policy and there is nowhere to store an override, so a handler would accept the checklist and silently discard it — the screen would appear to save and revert on reload. + +**Fix.** Needs a per-role permission table (ClubId, MembershipType, the 6 bools). Then register `[HttpPut("/club/{clubId:long}/permissions/{role:int}")]` binding those 6 form keys through `FormOrJsonModelBinder`, upsert the row owner-only, and have `PermissionsForRole` read the stored row (falling back to the current derived default when absent) so `BuildDetailsResponseAsync` reflects the save. + +##### `POST comments/create/{0}` — MISSING (degraded) + +No comments/create route (grep zero hits; only /comments/unreadcounts and an unrelated rooms/{id}/comments exist) → 404. Posting room feedback/idea comments fails. + +**Fix.** Add RoomComments CRUD to CommentsController: [HttpPost("/comments/create/{roomId:long}")] reading form message/subRoomId/style/positionX/Y/Z, persist a RoomCommentEntity, return a single RoomComment object (CommentId, RoomId, SubRoomId?, AccountId?, CreatedAt, position?, message, style, 2 bools — key casing UNKNOWN, dual-case via Dictionary and validate live). + +##### `DELETE comments/delete/{0}` — MISSING (degraded) + +No route → 404. Deleting a comment fails. + +**Fix.** Add [HttpDelete("/comments/delete/{commentId:long}")], status-only 200. + +##### `GET comments/get/{0}` — MISSING (degraded) + +No route → 404. Room-details comment list never loads ('Failed to get room comments'). + +**Fix.** Add [HttpGet("/comments/get/{roomId:long}")] with query count (int) + minId (long), returning JSON array of RoomComment. + +##### `PUT thread/message/{0}/moderate` — MISSING (degraded) — BLOCKED ON SCHEMA + +4-segment path matches no thread template → 404. Chat moderation (hide/flag) fails. + +Verb + shape re-verified from the binary: DLDKCILCKNA.txt `PLBCHNGAEML(ChatMessage, MOFGOGFEHNN)`, instr 065 `Move rcx, "thread/message/{0}/moderate"`, verb `Move rdx, 3` at instr 075 (PUT), host `Move r8, 8` (Chat), single form field `moderationState` at instr 087, response consumed by `Action` at instr 101 = bare int. + +**Fix.** Add `ChatMessageEntity.ModerationState` (int, default 0 = Active) + migration, emit it as the `ModerationState` key in `ChatController.ToWireMessage` (the ChatMessage reader CBKDKACJDOF.txt reads ChatMessageId / ChatThreadId / SenderPlayerId / TimeSent / Contents / ModerationState), then add `[HttpPut("/thread/message/{messageId:long}/moderate")]` reading form `moderationState` and returning bare int 0. Deliberately NOT stubbed: without the column the handler could only discard the request, which hides the gap behind a 200. + +##### `POST thread/{0}/rename` — FIXED (was verb_mismatch, degraded) + +Server registered PUT only with [FromBody] RenameRequest JSON; the client sends POST with form 'name' → 405 (and would 415 even as PUT). Renaming a group chat failed. + +Handler: `DorkNet.Server/Controllers/Chat/ChatController.cs` — `ChatController.Rename` + +**Fixed.** Route now carries both `[HttpPut]` and `[HttpPost]`; `RenameRequest` became a plain class with a parameterless ctor bound by `[ModelBinder(typeof(FormOrJsonModelBinder))]`, so the client's form `name` and the admin SPA's JSON both bind. Still returns the bare int (`Action` at DLDKCILCKNA.txt instr 162). Membership is now checked through `IsThreadMemberAsync`, which also understands club threads. + +##### `POST thread/{0}/snooze` — SHAPE_MISMATCH (degraded) + +Three defects for the 2023 client: (1) [FromBody] SnoozeRequest JSON → 415 on the form content-type; (2) the client's field is 'snooze' (bool), not an Until date — never bound; (3) the response is an object {chatResult, snoozedUntil} (:325-329) while the 2023 client deserializes a bare MGGNHLHPHOF enum — object-into-int throws even after the 415 is fixed. + +Handler: `DorkNet.Server/Controllers/Chat/ChatController.cs:307` + +**Fix.** Add a form-consuming variant: read Request.Form["snooze"] bool, set SnoozeUntil = snooze ? DateTime.MaxValue (or a policy window) : null, and return bare int 0; keep the JSON+object variant for the 2020 watch on the JSON content-type. + +##### `PUT comments/read/{0}/{1}` — MISSING (cosmetic) + +No route → 404. Unread-comment badge never clears. + +**Fix.** Add [HttpPut("/comments/read/{roomId:long}/{commentId:long}")] persisting per-player last-read comment id (also lets comments/unreadcounts return real counts), status-only 200. + +##### `GET comments/unreadcounts` — STUB (cosmetic) + +Route exists for both GET and POST with form+JSON+query parsing of roomIds — matches the client's dynamic verb (GET <100 ids, POST >=100). Wire shape verified correct against the client binary: the response IS a JSON array — OPELMBNJHNO.txt:856 (instr 109) constructs Func, Dictionary>, i.e. the dictionary in the method signature is a client-side projection; the task inventory's 'JSON object map' claim is wrong. Element key names are attribute-driven/UNKNOWN; server hedges with RoomId/UnreadCount/Count aliases (Json.NET matches case-insensitively). Defect: counts are hardcoded 0 (no read-state tracking) so the unread badge never appears. + +Handler: `DorkNet.Server/Controllers/API/Comments/CommentsController.cs:40` + +**Fix.** Once comments/read persists per-player read state, compute real counts (max comment id per room vs player's last-read). + +##### `PUT thread/{0}/favorite` — FIXED (was cosmetic) + +No route → 404. Starring a thread failed. + +**Fixed.** `ChatController.Favorite` — `[HttpPut]`+`[HttpPost]` on `/thread/{chatThreadId}/favorite`, form field `favorite` bound with FormOrJsonModelBinder. The response is NOT a bare int as previously recorded: the client's continuation is `Func` (DLDKCILCKNA.txt instr 142), i.e. a client-side projection off a wrapper object, so the handler returns `{chatResult, isFavorited}` (reader OGCMIDEFOEE.txt, both keys casing-tolerant). The flag is per-(player, thread) and there is no column for it, so it is persisted in the generic PlayerSettings table under `chat.favorite:` and read back into every thread DTO's `IsFavorited` via `LoadFavoritesAsync`. Unknown threads answer ThreadNotFound(2), non-members MembershipNotFound(3). + +### Relationships, friends and messaging + +`relationships-messaging` + +Audited all 20 real HTTP routes of the relationships-messaging subsystem against DorkNet (RelationshipsController.cs, MessagesController.cs, ExternalFriendInviteController.cs; global JSON options at ServiceCollectionExtensions.cs:380-381 confirm PascalCase-verbatim serialization and NO AllowReadingFromString). 11 routes match the 2023-03-21 client exactly (v2/get, v2/addfriend, v2/removefriend, v2/sendfriendrequest, v2/acceptfriendrequest, v1/addfriendwithcode, v1 mute/unmute/ignore/unignore, messages v2/get, v2/send, v3/delete, favoriteFriendOnlineStatus). 9 defects: (worst) both externalfriendinvite referrer endpoints return object arrays where the client requires bare [Int32,...] — a hard Utf8Json fault at login bootstrap once a player has any stored invite; sendMultiple 400s whenever the client attaches a room because RoomId arrives as a JSON string and AllowReadingFromString is unset; sendfriendintroductions binds [FromForm] against a JSON body and always 400s; bulkignoreplatformusers reads key PlatformUserIds but the client sends PlatformIds (silent zero-row import); mutualfriends binds playerId instead of id AND returns account objects instead of Relationship objects (always-empty mutual-friends UI); createplatforminvite's response lacks Success/Error so the client always sees failure; sendtextmessageinvite drops friendCode/senderName; favorite/unfavorite return RecNetResult instead of a Relationship (non-fatal); offlineinvite/v1/send is fully mismatched but has zero call sites in this build. Cross-cutting constraint: ECGNEHMCGCN.PlayerID and KJECOLODAFA.FromPlayerId are ReadInt32 on this client — player ids must stay <= Int32.MaxValue. + +**Client-side notes.** HOW I READ THE WIRE SHAPES. Response DTO JSON keys are NOT il2cpp string literals in the DTO types — this build serializes with a code-generated Utf8Json resolver, so each DTO has a sibling formatter type whose .ctor registers the key strings. Those ctors DO contain the literals (canonical PascalCase first, then the camelCase and all-lowercase automata variants). Formatter map used here: ECGNEHMCGCN -> RecNet.Runtime/FNIDAIDJBBO.txt; KJECOLODAFA -> KKOHONLGIHL.txt; PHMHCPEMABG -> GBPDOLJBABB.txt; DJMHAFPGLLN -> LMCCNLLHLCJ.txt; RecNet.DeleteMessagesRequestDTO -> AAKOLCABMCP.txt; PDNMHAFBHIB/KECFAGHHOKD -> JNCIDDKKHHN.txt. C:\tmp\recroom-2023-03-21-decompiled contains ONLY Assembly-CSharp, so RecNet.Runtime DTO layouts came from the Cpp2IL/Il2CppInterop dummy assemblies shipped in the dev package: "C:\tmp\recroom-2023-03-21-devdork-package\Z\Rec Room old versions\staging\7490748483298966814\MelonLoader\Dependencies\Il2CppAssemblyGenerator\Cpp2IL\cpp2il_out\RecNet.Runtime.dll" (field/enum constants) and ...\MelonLoader\Il2CppAssemblies\Il2CppRecNet.Runtime.dll (property CLR types). Il2CppDumper 6.7.46 fails on this binary (metadata v27 / il2cpp v29.1 overflow), so dump.cs is not available for 2023-03-21. + +REQUEST-ENCODING RULE (applies to every endpoint above). All requests are built by BNDIAONDFFF..ctor(HTTPMethods verb, GJDLNNLKDIJ host, String route) — fields [+16]=verb, [+20]=host, [+24]=route, [+32]=param list, [+40]=raw JSON body (RecNet.Runtime/BNDIAONDFFF.txt:74-124). Verb enum is BestHTTP.HTTPMethods: Get=0, Head=1, Post=2, Put=3, Delete=4, Patch=5, Merge=6, Options=7. Host enum GJDLNNLKDIJ: Auth=0, API=1, Commerce=2, ... — every route in this subsystem uses host 1 (api.). BNDIAONDFFF.FGHNOKLDOKO (BNDIAONDFFF.txt:2490, ISIL 072-073 vs 099-133) shows: if verb==Get the AFGEDDANEKP params become a '?'-joined QUERY STRING; otherwise they become a BestHTTP.Forms.HTTPUrlEncodedForm body (application/x-www-form-urlencoded). AFGEDDANEKP skips null values (ISIL 031) and expands IEnumerable values into repeated keys. FJLLPHFOOJJ sets a raw JSON string body instead. + +STRICTNESS. Unlike the 2020 client (LitJson + Util.GetKey, which throws on a missing key), the 2023 Utf8Json object formatters tolerate missing and unknown keys — a missing field silently becomes default(T). The real crash risks are TYPE mismatches: an object where an Int32 is expected (getplatformreferrers), or a JSON string where a number is expected. Enum-valued fields are safe either way: the enum formatter (RecRoom.Utf8json.Runtime/FMIHCGAPIOJ.txt:2187, ISIL 034-040 compares the token against 6=String and 5=Number) accepts both numeric and name form. + +INT32 vs INT64 TRAP. ECGNEHMCGCN.PlayerID and KJECOLODAFA.FromPlayerId are read with ReadInt32 (FNIDAIDJBBO.txt deserialize; KKOHONLGIHL.txt deserialize), while KJECOLODAFA.Id / RoomId / PlayerEventId are Int64. Serving a player id above Int32.MaxValue in those two fields will break the 2023 client even though the same server field is a long for the 2020 client. + +SERVER-SIDE GAPS FOUND WHILE CROSS-CHECKING (DorkNet at C:\Users\Alexa\Documents\Recnet). Every route in this subsystem is registered, but several bind or emit the wrong shape for THIS client: +1. api/externalfriendinvite/v1/getplatformreferrers + gettextmessagereferrers — DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs:46-66 returns an array of OBJECTS {InviteCode,Kind,Value,CreatedAt}; the client wants [Int32,...]. This is a hard deserialize fault, not a silent default. +2. api/externalfriendinvite/v1/createplatforminvite — same file :16-30 returns {InviteCode,InviteUrl,Platform,PlatformId}; client wants {"Success":bool,"Error":string} and will read Success=false. +3. api/externalfriendinvite/v1/sendtextmessageinvite — same file :32-44 binds [FromForm] phoneNumber + message; the 2023 client sends phoneNumber + friendCode + senderName (no "message"). Response should be {"Success","Error"}; the current {Success,InviteCode,PhoneNumber} only accidentally satisfies Success. +4. api/relationships/sendfriendintroductions — RelationshipsController.cs:475-489 binds [FromForm] playerId + introducedPlayerId; the 2023 client posts a JSON body {"ToPlayerIds":[…],"AboutPlayerId":N}. Nothing binds. Response should be {"Success","Message"}. +5. api/relationships/v1/bulkignoreplatformusers — RelationshipsController.cs:427-451 expects JSON key PlatformUserIds; the client sends PlatformIds. Zero rows imported, silently. +6. api/relationships/mutualfriends — RelationshipsController.cs:454-473 binds [FromQuery] playerId, but the 2023 client sends id, and it returns account objects {AccountId,Username,DisplayName,ProfileImage} where the client expects Relationship objects. Both param name and element shape are wrong. +7. api/relationships/v1/favorite|unfavorite — RelationshipsController.cs:354-360 returns RecNetResult {Success,Error}; the 2023 client parses the response as a Relationship (ECGNEHMCGCN). Non-fatal (defaults) but the wire contract is a Relationship. +8. api/offlineinvite/v1/send — MessagesController.cs:509-531 binds RecipientId + Data and returns {success,messageId} (lowercase); the 2023 client sends only PlayerId and reads {"Message":string}. Fully broken for this client (though no 2023 call site exists, so it is currently unreachable). +9. api/messages/v1/sendMultiple — MessagesController.cs:455-492 binds `long? RoomId`, but the 2023 client sends RoomId as a JSON STRING ("123"). Server JSON options (DorkNet.Server/Startup/ServiceCollectionExtensions.cs:380-381) only null the naming policy; NumberHandling.AllowReadingFromString is NOT set, so a sendMultiple that carries a room will 400. +Matching correctly today: api/relationships/v2/get, v2/addfriend, v2/removefriend, v2/sendfriendrequest, v2/acceptfriendrequest, v1/addfriendwithcode, v1/mute|unmute|ignore|unignore (server accepts both GET query id and POST form PlayerId), api/messages/v2/get (MessageDto keys Id/FromPlayerId/SentTime/Type/Data/RoomId/PlayerEventId are exactly right), api/messages/v2/send, api/messages/v3/delete, api/messages/v1/favoriteFriendOnlineStatus. + +#### Endpoints + +| Verb | Route | Request | Response | +|---|---|---|---| +| POST | `api/externalfriendinvite/v1/createplatforminvite` | application/x-www-form-urlencoded, single field: platformId= (lowercase p) | {"Success":Boolean, "Error":String}. Deserializer also accepts "success"/"error". Note this DTO uses Error, NOT Message. | +| POST | `api/externalfriendinvite/v1/getplatformreferrers` | none — POST with NO body and NO params | BARE JSON ARRAY OF INTEGERS: [Int32, ...] (player ids). NOT an array of objects — the Utf8Json Int32 formatter will throw on an object/string element and fault the client task. | +| POST | `api/externalfriendinvite/v1/gettextmessagereferrers` | none — POST with NO body and NO params | BARE JSON ARRAY OF INTEGERS: [Int32, ...] | +| POST | `api/externalfriendinvite/v1/sendtextmessageinvite` | application/x-www-form-urlencoded, three fields: phoneNumber=, friendCode=, senderName= | {"Success":Boolean, "Error":String} — same generic-instantiation as createplatforminvite (identical metadata token 0x188477F70 on the typed-parse call in both methods) | +| GET | `api/messages/v1/favoriteFriendOnlineStatus` | none | body is NOT parsed at all — BNDIAONDFFF.KDOPJCNKOOK only awaits status success. Any 2xx body (even empty) satisfies the client. | +| POST | `api/messages/v1/sendMultiple` | raw JSON body (Dictionary serialized by LFHNDFEFEOD.ODHIHDMAPDF with tag "SendMessageMultiple"): {"ToPlayerIds":[Int64,...], "Type":Int32, "Data":String, "RoomId":"< | body is NOT parsed (KDOPJCNKOOK, status-only) | +| GET | `api/messages/v2/get` | none (no query params) | BARE JSON ARRAY of Message (KJECOLODAFA). Element: {"Id":Int64, "FromPlayerId":Int32, "SentTime":DateTime (ISO-8601 string), "Type":Int32 enum MEPCALGGMJC, "Data":String, "RoomId":Int64\|null, "PlayerEventId":Int64\|null | +| POST | `api/messages/v2/send` | application/x-www-form-urlencoded fields: ToPlayerId=, Type=, Data=, RoomId= (omitted entirely when the Nullable has no value — AFGEDDANEKP | body is NOT parsed (BNDIAONDFFF.KDOPJCNKOOK, status-only). Any 2xx is success. | +| POST | `api/messages/v3/delete` | raw JSON body from RecNet.DeleteMessagesRequestDTO: {"MessageIds":[Int64,...]}. Deserializer-side variants accepted by the same DTO are "messageIds"/"messageids", but the client al | body is NOT parsed (KDOPJCNKOOK, status-only) | +| POST | `api/offlineinvite/v1/send` | application/x-www-form-urlencoded, single field: PlayerId=. No Data/RecipientId field is sent. | {"Message":String} — a single-key object. Deserializer also accepts "message". The client projects it to the String result. | +| GET | `api/relationships/mutualfriends` | query: id= (NOT playerId) | BARE JSON ARRAY of Relationship (ECGNEHMCGCN) — same element shape as v2/get: {"PlayerID":Int32,"RelationshipType":Int32,"Muted":Int32,"Ignored":Int32,"Favorited":Int32}. Response is client-cached via BNDIAONDFFF.AMDPEBK | +| POST | `api/relationships/sendfriendintroductions` | JSON body produced by UnityEngine.JsonUtility.ToJson over EEGNOHOELBG/SendFriendIntroductionsRequest — exact keys: {"ToPlayerIds":[Int32,...], "AboutPlayerId":Int32}. Content is a | {"Success":Boolean, "Message":String}. Deserializer also accepts "success"/"message". Missing keys default (false/null) without throwing. | +| GET | `api/relationships/v1/addfriendwithcode` | query: code= (AFGEDDANEKP(String,String,bool,bool) overload) | single Relationship object (see v2/get element shape) | +| POST | `api/relationships/v1/bulkignoreplatformusers` | JSON body via JsonUtility.ToJson over EEGNOHOELBG/BulkBlockPlatformUsersRequest — exact keys: {"Platform":Int32 (HHJIBNMLOAC: 0 All,1 Steam,2 Oculus,3 PlayStation,4 Xbox,5 RecNet,6 | body is NOT parsed (BNDIAONDFFF.KDOPJCNKOOK, status-only) | +| GET | `api/relationships/v1/favorite` | query: id= | single Relationship object (see v2/get element shape). Client discards it and projects to Boolean via a Func continuation. | +| GET | `api/relationships/v1/unfavorite` | query: id= | single Relationship object (see v2/get element shape), projected to Boolean client-side | +| GET | `api/relationships/v2/acceptfriendrequest` | query: id= | single Relationship object (see v2/get element shape) | +| GET | `api/relationships/v2/addfriend` | query: id= (BNDIAONDFFF.AFGEDDANEKP("id", boxed Int32) on a GET request => query string) | single Relationship object, same shape as v2/get elements: {"PlayerID":Int32,"RelationshipType":Int32,"Muted":Int32,"Ignored":Int32,"Favorited":Int32} | +| GET | `api/relationships/v2/get` | none (no query params, no body) | BARE JSON ARRAY of Relationship (ECGNEHMCGCN). Each element: {"PlayerID":Int32, "RelationshipType":Int32 enum (0 None,1 FriendRequestSent,2 FriendRequestReceived,3 Friend), "Muted":Int32 enum (0 None,1 Local,2 Remote,3 M | +| GET | `api/relationships/v2/removefriend` | query: id= | single Relationship object (see v2/get element shape) | +| GET | `api/relationships/v2/sendfriendrequest` | query: id= | single Relationship object (see v2/get element shape) | + +#### Not HTTP routes + +These literals look like paths but are cache keys, deeplinks or MIME types: + +- `api/relationships/` — Mute/unmute and block(ignore)/unblock from the player card and the personal block list (Assembly-CSharp/JKNCKAMFODO.txt, AGUI/StackedUI/PersonalBlockListScreen.txt) +- `friend/` — Generating the shareable friend-code URL when the player opens "share my friend code" + +#### Defects + +##### `POST api/externalfriendinvite/v1/getplatformreferrers` — SHAPE_MISMATCH (breaks-gameplay) + +Verb OK (GET+POST registered; client uses body-less POST). But the response is an array of OBJECTS {InviteCode,Kind,Value,CreatedAt} (:54, :68-78) while the client deserializes List — Utf8Json's Int32 formatter throws on an object element, faulting the client task during SessionManager login bootstrap. Today the list is empty ([]) for players who never used createplatforminvite, which parses fine — but as soon as a player creates one platform invite, every subsequent login of THAT player hits a non-empty array and the bootstrap task hard-faults. Semantics are also inverted: the route means 'player ids who referred ME', but the server returns MY OWN outgoing invite records. + +Handler: `DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs:46` + +**Fix.** In GetPlatformReferrers return a bare int array of referrer player ids for the CURRENT player as invitee. Requires tracking redemptions: when an externalinvite:platform code is redeemed by a new account, record (inviteePlayerId -> inviterPlayerId); the handler then selects those inviter ids as (int) and returns Ok(thatList). Until redemption tracking lands, returning Ok(Array.Empty()) is wire-safe, but per the no-stubs rule implement the redemption record with the fix. + +##### `POST api/externalfriendinvite/v1/gettextmessagereferrers` — SHAPE_MISMATCH (breaks-gameplay) + +Identical defect to getplatformreferrers: returns array of {InviteCode,Kind,Value,CreatedAt} objects where the client requires a bare [Int32,...]. Non-empty response (any player who used sendtextmessageinvite) hard-faults the login bootstrap task; also returns outgoing invites instead of referrers. + +Handler: `DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs:57` + +**Fix.** Same as getplatformreferrers: return bare int array of the player ids who referred the current player via text invite, backed by a redemption record keyed on the externalinvite:text code. + +##### `POST api/relationships/sendfriendintroductions` — SHAPE_MISMATCH (degraded) + +Server binds [FromForm] long playerId + [FromForm] long introducedPlayerId, but the 2023 client posts a raw JSON body {"ToPlayerIds":[Int32,...],"AboutPlayerId":Int32} (JsonUtility.ToJson + FJLLPHFOOJJ raw body). With a JSON content type the form value provider never runs, the two non-nullable value-type params fail to bind, and [ApiController] returns 400 — the 'Introduce my friends' flow from the watch social UI always fails. The response shape is also wrong for the client's PHMHCPEMABG reader ({"Success":bool,"Message":string}): the current Ok(new { Success = true }) at :491 would pass on Success but never runs. + +Handler: `DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs:475` + +**Fix.** Replace the [FromForm] binding with a [FromBody] DTO { public List? ToPlayerIds; public int AboutPlayerId; }, create one MessageEntity per recipient (Type 130 FriendIntroduction per the client's MEPCALGGMJC enum, Body carrying AboutPlayerId), and return Ok(new { Success = true, Message = "" }). + +##### `POST api/relationships/v1/bulkignoreplatformusers` — SHAPE_MISMATCH (degraded) + +Server's BulkIgnoreRequest (:417-421) has property PlatformUserIds, but the client's JSON body key is PlatformIds ({"Platform":Int32,"PlatformIds":[String,...]}). System.Text.Json leaves PlatformUserIds null, the guard at :432 returns {Imported:0}, and the Steam block-list import at login silently imports zero rows. Client does not parse the response (status-only), so no crash — just a silently non-functional platform block sync. + +Handler: `DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs:427` + +**Fix.** In BulkIgnoreRequest add a second property bound to the client's key — e.g. [JsonPropertyName("PlatformIds")] public List? PlatformIds — and in the handler use req.PlatformIds ?? req.PlatformUserIds (keep the old name for any 2020-era caller). + +##### `POST api/messages/v1/sendMultiple` — SHAPE_MISMATCH (degraded) + +SendMultipleBody.RoomId is long? (:461) but the 2023 client stringifies RoomId into the JSON body as a STRING ("RoomId":"123", Int64.ToString at PDNMHAFBHIB.txt:4176-4183). Server JSON options (ServiceCollectionExtensions.cs:380-381) only null the naming policy — NumberHandling.AllowReadingFromString is NOT set — so System.Text.Json throws during body deserialization and [ApiController] returns 400. Every fan-out invite that carries a room (the party-invite path in SessionManager_NestedType_FMGMGPGPBKE) fails; the client is status-only so the failure is silent. sendMultiple WITHOUT a room (RoomId omitted when null) works today. + +Handler: `DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs:467` + +**Fix.** Annotate SendMultipleBody (MessagesController.cs:456) with [System.Text.Json.Serialization.JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] (scoped to this DTO, avoiding a global behavior change), or change RoomId to string? and long.TryParse it in the handler. + +##### `POST api/externalfriendinvite/v1/createplatforminvite` — SHAPE_MISMATCH (degraded) + +Request binding is fine ([FromForm] platformId matches the client's lowercase form field; platform defaults 0). But the response {InviteCode,InviteUrl,Platform,PlatformId} contains neither key of the client's DJMHAFPGLLN reader {"Success":bool,"Error":string} — Utf8Json leaves defaults, so the client always sees Success=false and treats every platform-friend invite as failed (no success feedback in the RRUI add-friends contact list). + +Handler: `DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs:16` + +**Fix.** Return the invite-record keys plus the contract keys, e.g. Ok(new Dictionary{["Success"]=true,["Error"]="",["InviteCode"]=code,["InviteUrl"]=url}) in ExternalFriendInviteController.CreatePlatformInvite. + +##### `GET api/relationships/v1/favorite` — SHAPE_MISMATCH (cosmetic) + +Server returns RecNetResult {"success":bool,"error":string} (DorkNet.Models/Auth/RecNetResult.cs:29-36), but the 2023 client parses the body as a Relationship (ECGNEHMCGCN). Utf8Json tolerates the unknown keys, so the client gets a default Relationship (PlayerID=0, Favorited=0) instead of the updated row. Non-fatal — the flag IS persisted (SetPreference :385-415) and resurfaces via v2/get — but the immediate response object is all-defaults, so any client logic reading the returned Favorited bit sees 0. + +Handler: `DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs:354` + +**Fix.** In RelationshipsController.cs, make SetPreference (for the favorite/unfavorite call sites at :356/:360) return the caller's merged Relationship dictionary — after SaveChangesAsync, reload the pair rows and return Ok(BuildMerged(me, otherId, pair)) instead of Ok(new RecNetResult...). The mute/ignore call sites can share this (their responses are status-only on the client). + +##### `GET api/relationships/v1/unfavorite` — SHAPE_MISMATCH (cosmetic) + +Identical to v1/favorite: returns RecNetResult instead of a Relationship object; client reads all-default Relationship. Flag persists correctly server-side. + +Handler: `DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs:358` + +**Fix.** Same as favorite — return BuildMerged from SetPreference. + +##### `GET api/relationships/mutualfriends` — SHAPE_MISMATCH (cosmetic) + +TWO defects. (1) Binds [FromQuery] long playerId but the 2023 client sends ?id=, so playerId binds 0 and the guard at :458 always returns an empty array — the 'X mutual friends' text is permanently 0/absent for every profile. (2) Even if the param bound, the element shape is {AccountId, Username, DisplayName, ProfileImage} (:462-471) while the client deserializes List and reads PlayerID/RelationshipType/Muted/Ignored/Favorited — unknown keys are ignored, so every element would be an all-default Relationship (PlayerID=0). Empty/duplicate-zero list means the mutual-friends UI silently shows nothing; no crash. + +Handler: `DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs:454` + +**Fix.** In MutualFriends: bind [FromQuery(Name = "id")] long id (keep playerId as a fallback alias if desired), and return the mutual ids as Relationship dictionaries — for each mutual id, load the caller's pair rows and emit BuildMerged(me, mutualId, pair) (or SynthFriend under global-friends mode) so each element carries PlayerID/RelationshipType=3/Muted/Ignored/Favorited. + +##### `POST api/externalfriendinvite/v1/sendtextmessageinvite` — SHAPE_MISMATCH (cosmetic) + +Server binds [FromForm] phoneNumber + message, but the 2023 client sends phoneNumber + friendCode + senderName (there is no 'message' field). Both server params are nullable so binding succeeds; friendCode and senderName are silently dropped, and the stored record has an empty message slot. Response {Success:true,InviteCode,PhoneNumber} accidentally satisfies the client's {"Success","Error"} reader (Success=true, Error defaults null), so the SMS-invite UI reports success. Defect is data loss (the friend code that was supposed to be texted is never persisted), not a client fault. + +Handler: `DorkNet.Server/Controllers/API/ExternalFriendInvite/ExternalFriendInviteController.cs:32` + +**Fix.** Change SendTextMessageInvite's binding to [FromForm] string? phoneNumber, [FromForm] string? friendCode, [FromForm] string? senderName; persist all three in the Value string; return {"Success":true,"Error":""} (Dictionary or PascalCase DTO, plus InviteCode if wanted). + +##### `POST api/offlineinvite/v1/send` — SHAPE_MISMATCH (none) + +Server binds [FromForm] RecipientId + Data, but this client sends a single form field PlayerId; recipientId binds null and the handler returns 200 {success:false,error:"invalid_recipient"} (lowercase keys). The client would read {"Message":string} => null. Fully mismatched — but NO call site exists anywhere in the 2023-03-21 dump (dead API surface), so nothing is currently reachable/broken for this client. Severity 'none' for this build; still wrong wire for completeness. + +Handler: `DorkNet.Server/Controllers/API/Messages/V2/MessagesController.cs:513` + +**Fix.** If closing the gap: additionally bind [FromForm(Name="PlayerId")] long? playerId, use it as the recipient when RecipientId is absent, and return Ok(new Dictionary{["Message"]="ok"}) alongside the existing keys (a Dictionary sidesteps the camelCase anonymous-object trap). + +### Player events, playlists and keepsakes + +`events-playlists` + +Verified all 64 real client routes against DorkNet.Server. Telemetry, report, keepsake categories/globalconfig/rooms, progression active/event, and most playlist reads are OK. Broken clusters: (1) PlayerEvents — GET v1 list, GET v1/{id}/responses, POST v2 create/edit, POST v2/delete/{id} are wrong-verb (405); respond/deleteResponse read form+return bare int vs JSON+{Result}; club/{id} lacks {Events,ContinuationToken} wrapper; v1/all emits raw entity keys; broadcast implements a different feature; every v2 field-PUT returns a flat event instead of the PHHAKLPGNGC wrapper, and the tags/multiinstance/club PUTs also misread the request. (2) Playlists — bulk is GET-only; warning/accessibility/restrictions/levelvoting are POST-only no-ops; name/description/image/tags PUTs have [FromBody] params that 415 the client's form bodies. (3) Keepsakes — POST create has wrong request DTO and returns an object where the client needs a bare Guid; DELETE and collect routes missing; events/{id} shape wrong. (4) Progression — record DTO keys wrong; collect, xpboosts, previewEarnedXp routes missing. (5) event/{id}/instances (matchmaking host) not registered. Server serializes with PropertyNamingPolicy=null (ServiceCollectionExtensions.cs:380), and the 2023 Utf8Json readers accept Pascal/camel casings, so casing itself is not a defect anywhere; missing keys and verbs are. + +**Client-side notes.** METHOD: verbs read from the BestHTTP.HTTPMethods immediate (0=GET,2=POST,3=PUT,4=DELETE) passed to the shared request helpers (CBKANFIOBCF.OHOGDIIEIJK-family, NLDBPDCNNCF named-op helper where verb sits in r8, BNDIAONDFFF ctor where verb is rdx). JSON keys read from Utf8Json generated-formatter ctors in the ISIL (each formatter lists reader-accepted casings Pascal/camel/lower per member, then the written PascalCase key array); member CLR types from the DTO type's getter signatures and the formatter's JECENNBIMEI typeof fetches. Enums (BAMLHAODDOG accessibility, AKFCIMLMAKA broadcast-perm, CAMMJINEJFD response type, BNCFHOOCHAI result, HMECHOKOCBB keepsake category, NGPHEBMBPIC warning, JHAOFLCJNAL accessibility) have no string-enum formatter in the dump -> serialized as integers. Servers may emit any accepted casing; PascalCase is what the client itself writes. + +CLIENT ARCHITECTURE: CBKANFIOBCF = playerevents API client, EMMAMFINMMJ = PlayerEventService cache layer (its underscore literals are cache keys, not routes), NLDBPDCNNCF = rooms+playlists API client, NCCLEJPIABA = keepsakes client, CDBFONFHJDO = progressionEvents client, JDHIDKCKHDK = GameSight telemetry, RecNet.Matchmaking issues event/{id}/instances from the matchmaking host root. Two request-body styles coexist: Utf8Json DTO bodies (respond, report, v2 create/edit, broadcast, keepsake create) and UnityEngine.JsonUtility bodies (deleteResponse, bulkInvite) whose field names are NOT recoverable from the ISIL (fields only, no methods) - server should keep accepting multiple key spellings there. + +SERVER GAPS FOUND (DorkNet.Server): (1) POST api/playerevents/v2 and POST api/playerevents/v2/{id} not registered (create/edit 405) and POST api/playerevents/v2/delete/{id} is HttpDelete-only - event creation/editing/deletion is broken for the 2023 client; (2) GET api/playerevents/v1 is POST-only; GET v1/{id}/responses is POST-only; (3) v1/respond + v1/deleteResponse read form fields and return bare ints, but 2023 sends JSON {PlayerEventId,Type} and expects {Result:int}; (4) v1/club/{id} must return {Events,ContinuationToken}; v1/all must return {Created:[PlayerEvent],Responses:[{PlayerEvent,PlayerEventResponse}]}; tagfilters missing TrendingFilters; broadcast is a different feature (set BroadcastingRoomInstanceId, request {PlayerEventId,BroadcastRoomInstanceId}, respond PHHAKLPGNGC); v2 field-PUTs should return the PHHAKLPGNGC wrapper; (5) playlists/bulk is GET-only but client POSTs form 'id'/'name'; playlists {accessibility,levelvoting,restrictions,warning} are POST-only but client PUTs (PlaylistsController.cs:118-122,242); (6) keepsakes: POST api/keepsakes must take {RoomId,SubRoomId,KeepsakeCategory} and return a bare Guid; DELETE api/keepsakes/{guid} and POST api/keepsakes/{guid}/collect ({TotalXp,SocialBoostXp}) are missing; api/keepsakes/events/{id} must return KeepsakeRoomInstanceIdsDTO lists; (7) progressionEvents record DTO keys wrong (client wants AccountId/Xp/GameMinutesToday/RewardsCollected/BonusRewardsCollected/XpBoostLastPurchasedAt); collect/{eventId}/{rewardIndex} (POST -> GiftDrop), {id}/xpboosts, {id}/xpboosts/{boostId}/previewEarnedXp missing; (8) event/{eventId}/instances (matchmaking-host instance browser, List<{RoomInstanceId,RoomId,SubRoomId,IsFull,CreatedAt,PlayerIds}>) not registered - only room/{roomId}/instances exists. + +Also observed but outside this group's literal list: POST playlists (create, NLDBPDCNNCF.COBELCLDHFE, form 'name') and GET playlists (by name, query 'name') use the bare 'playlists' literal; rooms/curated_playlists GET -> List is registered server-side. + +#### Endpoints + +| Verb | Route | Request | Response | +|---|---|---|---| +| POST | `api/gamesight/event` | form field 'EventData' = Utf8Json-serialized object with snake_case properties: {"type":String,"user_id":String,"identifiers":{"resolution":String,"language":String,"timezone":Stri | ignored by client (plain Task) - 200 with any/empty body | +| POST | `api/keepsakes` | JSON body: {"RoomId":Int64,"SubRoomId":Int64?,"KeepsakeCategory":Int32(enum HMECHOKOCBB)} | bare JSON GUID string (the new KeepsakeInstanceId) - NOT an object | +| GET | `api/keepsakes/categories` | none | {"Results":[{"KeepsakeCategoryId":Int32(enum HMECHOKOCBB),"VisualId":String,"LimitPerRoom":Int32,"XpValue":Int32,"IconOutlineImageName":String,"IconFilledImageName":String}],"TotalResults":Int32} - paged wrapper, not a b | +| GET | `api/keepsakes/events/{progressionEventId}` | none | {"Instances":[{"RoomId":Int64,"KeepsakeInstanceIds":[Guid]}],"CollectionRecords":[{"RoomId":Int64,"KeepsakeInstanceIds":[Guid]}]} (both List) | +| GET | `api/keepsakes/globalconfig` | none | {"KeepsakeFeatureEnabled":Boolean,"KeepsakeRoomLimit":Int32,"SocialXpBoostEnabled":Boolean} | +| GET | `api/keepsakes/rooms/{roomId}` | none | {"Instances":[{"KeepsakeInstanceId":Guid,"KeepsakeCategoryConfigId":Int32(enum HMECHOKOCBB),"PlacedByAccountId":Int32,"RoomId":Int64,"SubRoomId":Int64?}],"CollectionRecords":[{"AccountId":Int32,"KeepsakeInstanceId":Guid, | +| DELETE | `api/keepsakes/{keepsakeInstanceId:guid}` | none (path = String.Format("{0}/{1}", "api/keepsakes", guid)) | no body consumed; 200 suffices | +| POST | `api/keepsakes/{keepsakeInstanceId:guid}/collect` | none | {"TotalXp":Int32,"SocialBoostXp":Int32} | +| GET | `api/playerevents/v1` | none | JSON array of HPIOAGDJHDH (see v1/{0}) | +| GET | `api/playerevents/v1/all` | none | {"Created":[HPIOAGDJHDH],"Responses":[{"PlayerEvent":HPIOAGDJHDH,"PlayerEventResponse":{"PlayerEventResponseId":Int64,"PlayerEventId":Int64,"PlayerId":Int32,"CreatedAt":DateTime,"Type":Int32(enum CAMMJINEJFD)}}]} | +| GET | `api/playerevents/v1/all/{0}` | none | same as api/playerevents/v1/all | +| POST | `api/playerevents/v1/broadcast` | JSON body: {"PlayerEventId":Int64,"BroadcastRoomInstanceId":Int64?} (null clears) | PHHAKLPGNGC wrapper | +| POST | `api/playerevents/v1/bulk` | form field 'Ids' (generic list overload of request-builder AFGEDDANEKP; event ids) | JSON array of HPIOAGDJHDH | +| POST | `api/playerevents/v1/bulkInvite` | JSON body via JsonUtility.ToJson(RecNet.Events.BulkInviteRequest); field names not in ISIL (fields only); server-side DTO {PlayerEventId, InvitedPlayerIds:List} matches the me | {"FailedInvites":[{"InvitedPlayerId":Int32,"Result":Int32(enum BNCFHOOCHAI)}],"Result":Int32(enum BNCFHOOCHAI)} | +| GET | `api/playerevents/v1/club/{0}` | query: take (Int32, only when non-null), continuationToken (String, only when non-null) | {"Events":[HPIOAGDJHDH],"ContinuationToken":String} (both required keys) | +| GET | `api/playerevents/v1/clubs` | query: repeated 'id' per club id | JSON array of HPIOAGDJHDH | +| POST | `api/playerevents/v1/deleteResponse` | JSON body via UnityEngine.JsonUtility.ToJson(RecNet.Events.DeleteResponseRequest) carrying eventId (Int64 at +0x10) and response type (enum at +0x18); exact JSON field names UNKNOW | {"Result":Int32(enum BNCFHOOCHAI)} | +| POST | `api/playerevents/v1/report` | JSON body: {"ReportCategory":Int32(enum),"PlayerEventId":Int64,"Details":String} | {"Success":Boolean,"Message":String} | +| POST | `api/playerevents/v1/respond` | JSON body (Utf8Json): {"PlayerEventId":Int64,"Type":Int32(enum CAMMJINEJFD)} | {"Result":Int32(enum BNCFHOOCHAI)} - an OBJECT, not the 2020 bare int | +| GET | `api/playerevents/v1/room/{0}` | none | JSON array of HPIOAGDJHDH | +| GET | `api/playerevents/v1/search` | query: query (String), sort (Int32 enum PJBLEKMMACM), scheduleFilter (Int32 enum BJBLPLKMLBE, omitted when null) | JSON array of HPIOAGDJHDH | +| GET | `api/playerevents/v1/searchlive` | query: query (String) | JSON array of CLDDIKOJMAM = HPIOAGDJHDH keys + {"PlayerCount":Int32,"IsFull":Boolean} | +| GET | `api/playerevents/v1/tagfilters` | none | {"PinnedFilters":[String],"PopularFilters":[String],"TrendingFilters":[String]} | +| GET | `api/playerevents/v1/{0}` | query: includeDetails (Boolean; lambda __c), OAKKDBLNKLG variant adds clubId (Int64; lambda DEIBMMABHIK keys 'includeDetails','clubId') | HPIOAGDJHDH: {"PlayerEventId":Int64,"CreatorPlayerId":Int32,"RoomId":Int64,"SubRoomId":Int64?,"ClubId":Int64?,"Name":String,"Description":String,"ImageName":String,"StartTime":DateTime,"EndTime":DateTime,"AttendeeCount": | +| GET | `api/playerevents/v1/{0}/responses` | none | JSON array of {"PlayerEventResponseId":Int64,"PlayerEventId":Int64,"PlayerId":Int32,"CreatedAt":DateTime,"Type":Int32(enum CAMMJINEJFD)} | +| POST | `api/playerevents/v2` | JSON body: {"RoomId":Int64,"SubRoomId":Int64?,"ClubId":Int64?,"Name":String,"Description":String,"Tags":[String],"ImageName":String,"StartTime":DateTime,"EndTime":DateTime,"Accessi | {"PlayerEvent":MDCBEPJCJPO(HPIOAGDJHDH+Tags),"Result":Int32(enum BNCFHOOCHAI),"TagModifyResult":{"Result":Int32(enum OFLPPEFGGOP),"Tags":[String]}} | +| POST | `api/playerevents/v2/delete/{0}` | none (empty POST) | PHHAKLPGNGC wrapper | +| POST | `api/playerevents/v2/{0}` | same JSON body as POST api/playerevents/v2 | PHHAKLPGNGC (see v2 create) | +| PUT | `api/playerevents/v2/{0}/accessibility` | form field: accessibility (Int32 enum) | PHHAKLPGNGC wrapper {PlayerEvent,Result,TagModifyResult} | +| PUT | `api/playerevents/v2/{0}/club` | form field: clubId (Int64, only when non-null; omitted = clear) | PHHAKLPGNGC wrapper | +| PUT | `api/playerevents/v2/{0}/description` | form field: description (String) | PHHAKLPGNGC wrapper | +| PUT | `api/playerevents/v2/{0}/image` | form field: imageName (String) | PHHAKLPGNGC wrapper | +| PUT | `api/playerevents/v2/{0}/multiinstance` | form fields: isMultiInstance (Boolean), supportsMultiInstanceRoomChat (Boolean), defaultBroadcastPermissions (Int32 enum), canRequestBroadcastPermissions (Int32 enum) | PHHAKLPGNGC wrapper | +| PUT | `api/playerevents/v2/{0}/name` | form field: name (String) | PHHAKLPGNGC wrapper | +| PUT | `api/playerevents/v2/{0}/room` | form fields: roomId (Int64), subRoomId (Int64, only when non-null) | PHHAKLPGNGC wrapper | +| PUT | `api/playerevents/v2/{0}/tags` | JSON body = bare array of tag strings (lambda serializes the List via Utf8Json static ALHIJCJOLCB and sets it as body; no field name) | PHHAKLPGNGC wrapper (TagModifyResult carries accepted tags) | +| PUT | `api/playerevents/v2/{0}/time` | form fields: startTime, endTime (DateTime strings) | PHHAKLPGNGC wrapper | +| GET | `api/progressionEvents/active` | none | bare JSON integer = active progression event id (client wraps as Int64?) | +| POST | `api/progressionEvents/collect/{eventId}/{rewardIndex}` | none | gift-drop object: {"Id":...,"FromPlayerId":...,"ConsumableItemDesc":...,"AvatarItemType":...,"AvatarItemDesc":...,"EquipmentPrefabName":String,"EquipmentModificationGuid":...,"CurrencyType":...,"Currency":...,"Xp":Int32, | +| GET | `api/progressionEvents/event/{eventId}` | none | {"ProgressionEventId":Int64,"Name":String,"Rewards":[{"ProgressionEventRewardId":Int64,"GiftDropId":Int64,"ImageName":String,"Xp":Int32,"RewardIndex":Int32,"IsBonus":Boolean}],"KeepsakeRoomLists":[{"KeepsakeRoomListId":. | +| GET | `api/progressionEvents/record/{progressionEventId}` | none | {"AccountId":Int32,"Xp":Int32,"GameMinutesToday":Int32,"RewardsCollected":Int32,"BonusRewardsCollected":Int32,"XpBoostLastPurchasedAt":DateTime?} | +| GET | `api/progressionEvents/{eventId}/xpboosts` | none | JSON array of {"ProgressionEventPurchasableXpBoostId":Guid,"Cost":Int32,"XpMultiplier":number,"XpCap":Int32,"LookbackDurationTicks":Int64,"CooldownDurationTicks":Int64} | +| GET | `api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp` | none | bare JSON integer (XP the boost would grant) | +| POST | `data/event` | form fields: eventType (String), eventParams (serialized parameter bag) | ignored by client (fire-and-forget; error log 'Failed to send event data') - 200 empty is fine | +| GET | `event/{0}/instances` | none (path served from the matchmaking-host root, same builder as matchmake/* and room/{0}/instances) | JSON array of {"RoomInstanceId":Int64,"RoomId":Int64,"SubRoomId":Int64,"IsFull":Boolean,"CreatedAt":DateTime,"PlayerIds":[Int32]} | +| POST | `playlists/bulk` | form fields: repeated 'id' (Int64 overload) or repeated 'name' (String overload) | JSON array of CNIPGJIIFJF | +| GET | `playlists/cheeredby/me` | none | JSON array of CNIPGJIIFJF | +| GET | `playlists/createdby/me` | none | JSON array of CNIPGJIIFJF | +| GET | `playlists/favoritedby/me` | none | JSON array of CNIPGJIIFJF | +| GET | `playlists/visitedby/me` | none | JSON array of CNIPGJIIFJF | +| GET | `playlists/{0}` | details variant: query include (bitmask; __c lambda) | CNIPGJIIFJF: {"PlaylistId":Int64,"Name":String,"Description":String,"ImageName":String,"WarningMask":Int32(enum NGPHEBMBPIC),"CustomWarning":String,"CreatorAccountId":Int32,"State":Int32(enum),"Accessibility":Int32(enum | +| DELETE | `playlists/{0}` | none | no body consumed (Task); 200 suffices | +| PUT | `playlists/{0}/accessibility` | form field: accessibility (Int32 enum JHAOFLCJNAL) | DAFNFCFDPJB | +| PUT | `playlists/{0}/description` | form field: description (String) | DAFNFCFDPJB | +| PUT | `playlists/{0}/image` | form field: imageName (String) | DAFNFCFDPJB | +| GET | `playlists/{0}/interactionby/me` | none | {"Cheered":Boolean,"Favorited":Boolean,"LastVisitedAt":DateTime?} | +| PUT | `playlists/{0}/interactionby/me/cheer` | none | no body consumed; 200 suffices. Client then invalidates cache key playlistinteraction/{id} | +| PUT | `playlists/{0}/interactionby/me/favorite` | none | no body consumed; 200 suffices | +| PUT | `playlists/{0}/levelvoting` | form field: supportsLevelVoting (Boolean) | DAFNFCFDPJB | +| PUT | `playlists/{0}/name` | form field: name (String) | DAFNFCFDPJB (full playlist details) | +| PUT | `playlists/{0}/restrictions` | form fields: supportsJuniors, supportsScreens, supportsTeleportVR, supportsWalkVR (Booleans) | DAFNFCFDPJB | +| PUT | `playlists/{0}/rooms/{1}` | none (empty PUT to add; DELETE to remove) | DAFNFCFDPJB | +| PUT | `playlists/{0}/tags` | form fields: repeated 'tag' and repeated 'autoTag' | DAFNFCFDPJB | +| PUT | `playlists/{0}/warning` | form fields: warningMask (Int32 enum), customWarning (String) | DAFNFCFDPJB | + +#### Not HTTP routes + +These literals look like paths but are cache keys, deeplinks or MIME types: + +- `api/keepsakes/events` — Cache invalidation after keepsake place/collect +- `api/progressionEvents` — Cache invalidation +- `event/{0}/{1}` — Share-event code/link generation +- `event_responses/{0}` — RSVP-list cache in EMMAMFINMMJ +- `events_by_club_ids/` — Club-events cache +- `meetup/` — Meetup share-code link generation +- `playlistinteraction/{0}` — Playlist interaction cache +- `remote_player_event_list/{0}` — Profile events cache +- `room_events/{0}` — Room events cache + +#### Defects + +##### `GET api/playerevents/v1` — VERB_MISMATCH (breaks-gameplay) + +Only [HttpPost("api/playerevents/v1")] (Create) is registered on this path; GET returns 405. The watch Events tab upcoming-events list (CBKANFIOBCF.HIHEFBMPOGC) fails entirely. The GET list handler that exists is on api/playerevents/v2 (line 32), which this client never GETs as a list. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:281` + +**Fix.** Add [HttpGet("api/playerevents/v1")] returning a JSON array of full HPIOAGDJHDH-shaped events (reuse the fixed ToWire). + +##### `GET api/playerevents/v1/club/{0}` — SHAPE_MISMATCH (breaks-gameplay) + +Handler returns a bare JSON array (line 119: Ok(rows.Select(ToWire))). Client deserializes IOKLNPFOLGI, an OBJECT {"Events":[...],"ContinuationToken":string}; array-vs-object makes the Utf8Json reader throw and the club events tab errors out. take/continuationToken query params are also ignored (no paging). + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:99` + +**Fix.** Return Ok(new { Events = rows.Select(ToWire), ContinuationToken = "" }) (non-null string), honoring take/continuationToken for paging. + +##### `GET api/playerevents/v1/all` — SHAPE_MISMATCH (breaks-gameplay) + +Created items are raw entity projections {Id,Title,Description,RoomId,StartsAt,EndsAt,Capacity} (lines 187-196) — none of the HPIOAGDJHDH keys match, so every created event deserializes as all-defaults. Responses items are flat {Id,EventId,Response,CreatedAt} (lines 201-207) but the client reads [{"PlayerEvent":{...},"PlayerEventResponse":{PlayerEventResponseId,PlayerEventId,PlayerId,CreatedAt,Type}}] — PlayerEvent comes back null and the My Events page breaks/NREs. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:177` + +**Fix.** Emit Created via ToWire and Responses as {PlayerEvent:ToWire(ev), PlayerEventResponse:{PlayerEventResponseId:r.Id, PlayerEventId:r.EventId, PlayerId:(int)r.PlayerId, CreatedAt:r.CreatedAt, Type:r.Response}} joining each response row to its event. + +##### `GET api/playerevents/v1/all/{0}` — SHAPE_MISMATCH (breaks-gameplay) + +Identical raw-entity-key defect as v1/all (lines 217-243). Another player's profile events page gets null PlayerEvents. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:214` + +**Fix.** Same wrapper/key fix as v1/all. + +##### `GET api/playerevents/v1/{0}/responses` — VERB_MISMATCH (breaks-gameplay) + +Path registered as [HttpPost] only (RespondList RSVP alias). Client GETs it for the attendee list -> 405; event details attendee list broken. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:250` + +**Fix.** Add [HttpGet("api/playerevents/v1/{eventId:long}/responses")] returning [{PlayerEventResponseId,PlayerEventId,PlayerId,CreatedAt,Type}] from PlayerEventResponses (Type = stored Response int). + +##### `POST api/playerevents/v1/respond` — SHAPE_MISMATCH (breaks-gameplay) + +Handler binds [FromForm] EventId/Response, but the 2023 client sends a JSON body {"PlayerEventId":long,"Type":int} — the form provider yields nothing, eventId stays null, and the handler returns bare int 2 (NoSuchEvent) without ever writing the RSVP. The client then Deserialize-s an OBJECT {"Result":int} from the bare int and throws. RSVP button fully broken. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:344` + +**Fix.** Accept JSON {PlayerEventId,Type} (keep form fallback for 2020), and return Ok(new { Result = 0 }). + +##### `POST api/playerevents/v1/deleteResponse` — SHAPE_MISMATCH (breaks-gameplay) + +Same defect pair as respond: form-only read of EventId (client sends a JsonUtility JSON body whose exact field names are unrecoverable from the ISIL) and bare-int response where the client expects {"Result":int}. Un-RSVP broken. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:363` + +**Fix.** Parse the JSON body accepting multiple candidate keys (eventId/EventId/PlayerEventId/playerEventId and type/Type/response/Response), keep form fallback, return {Result:0}. + +##### `POST api/playerevents/v2` — VERB_MISMATCH (breaks-gameplay) + +Path only has [HttpGet] (ListUpcoming). The 2023 create-event flow POSTs a 14-field JSON body here -> 405. Event creation is impossible. (POST api/playerevents/v1 exists but the 2023 client never uses it.) + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:32` + +**Fix.** Add [HttpPost("api/playerevents/v2")] binding BIEFKAOABMP {RoomId,SubRoomId,ClubId,Name,Description,Tags,ImageName,StartTime,EndTime,Accessibility,IsMultiInstance,SupportMultiInstanceRoomChat,DefaultBroadcastPermissions,CanRequestBroadcastPermissions} and returning the PHHAKLPGNGC wrapper {PlayerEvent:, Result:0, TagModifyResult:{Result:0,Tags:[...]}}. + +##### `POST api/playerevents/v2/{0}` — VERB_MISMATCH (breaks-gameplay) + +Path only has [HttpGet]. Edit-event save POSTs the same BIEFKAOABMP body -> 405. Editing impossible. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:48` + +**Fix.** Add [HttpPost("api/playerevents/v2/{eventId:long}")] applying the full-body edit and returning the PHHAKLPGNGC wrapper. + +##### `PUT api/playerevents/v2/{0}/tags` — SHAPE_MISMATCH (breaks-gameplay) + +PUT registered, but the client body is a BARE JSON ARRAY of tag strings and ReadRequestFieldsAsync only parses JSON OBJECTS (line 742 checks ValueKind==Object) — the array is silently dropped, ReadStringFieldAsync returns null, and the handler stores empty string: saving tags always clears them. Tags are also stored in the side-table and never emitted; flat response instead of wrapper (whose TagModifyResult.Tags the client reads back). + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:468` + +**Fix.** When Content-Type is JSON and root is an array, read it as List; persist tags on the entity; return wrapper with TagModifyResult:{Result:0,Tags:accepted}. + +##### `PUT api/playerevents/v2/{0}/multiinstance` — SHAPE_MISMATCH (breaks-gameplay) + +PUT registered but reads field 'multiInstance' — the client sends 'isMultiInstance', 'supportsMultiInstanceRoomChat', 'defaultBroadcastPermissions', 'canRequestBroadcastPermissions'; none are read (case-insensitive dict, but names differ), so the setting is always stored false and the three other fields are dropped entirely. Flat response instead of wrapper. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:479` + +**Fix.** Read the four actual field names, persist all four on the entity (they are also HPIOAGDJHDH response keys), return wrapper. + +##### `POST api/playerevents/v1/broadcast` — SHAPE_MISMATCH (breaks-gameplay) + +Handler implements a text-message broadcast: binds {PlayerEventId,Message} and messages all RSVPs, returning {Success,Sent}. The 2023 client's feature is 'set/clear the broadcasting room instance': body {"PlayerEventId":long,"BroadcastRoomInstanceId":long?} and response PHHAKLPGNGC. BroadcastRoomInstanceId is never stored, the response shape is wrong, and as a bonus every RSVP gets a spurious message. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:573` + +**Fix.** Rewrite: bind {PlayerEventId,BroadcastRoomInstanceId?}, persist it as the event's BroadcastingRoomInstanceId (null clears), return the PHHAKLPGNGC wrapper. + +##### `POST api/playerevents/v2/delete/{0}` — VERB_MISMATCH (breaks-gameplay) + +Registered [HttpDelete] only; the 2023 client sends verb 2 (POST) -> 405, delete button broken. Response {success,error} is also wrong — client reads the PHHAKLPGNGC wrapper. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:375` + +**Fix.** Add [HttpPost("api/playerevents/v2/delete/{id:long}")] (keep HttpDelete) and return {PlayerEvent:, Result:0, TagModifyResult:{Result:0,Tags:[]}}. + +##### `POST playlists/bulk` — VERB_MISMATCH (breaks-gameplay) + +[HttpGet] only, reading query 'id'/'ids'. The 2023 client POSTs a form with repeated 'id' (or repeated 'name' for the by-name overload) -> 405. Hot/featured playlist hydration fails. No 'name' lookup exists at all. + +Handler: `DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:242` + +**Fix.** Add [HttpPost("/playlists/bulk")] reading form fields 'id' (repeated) and 'name' (repeated, resolve by Name), returning the same bare list of union entries. + +##### `PUT playlists/{0}/name` — SHAPE_MISMATCH (breaks-gameplay) + +PUT is registered, but the action declares a non-optional [FromBody] StringFieldRequest parameter alongside [FromForm]. The 2023 client sends application/x-www-form-urlencoded ('name'); with [ApiController], the JSON input formatter rejects the form content type and the request dies with 415 before the action runs. Rename broken. (Field-name case 'name' vs 'Name' would be fine — form binding is case-insensitive.) + +Handler: `DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:77` + +**Fix.** Drop the [FromBody] parameter and read form/JSON manually (like PlayerEventsController.ReadRequestFieldsAsync), then keep returning BuildDetailsResponseAsync (that response shape matches DAFNFCFDPJB). + +##### `PUT playlists/{0}/description` — SHAPE_MISMATCH (breaks-gameplay) + +Same [FromBody]+form 415 defect as playlists/{0}/name. + +Handler: `DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:84` + +**Fix.** Same manual field-reading fix. + +##### `PUT playlists/{0}/image` — SHAPE_MISMATCH (breaks-gameplay) + +Same [FromBody]+form 415 defect; client sends form field 'imageName' (would bind to the [FromForm(Name="ImageName")] fallback case-insensitively once the FromBody parameter is removed). + +Handler: `DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:91` + +**Fix.** Same manual field-reading fix. + +##### `PUT playlists/{0}/tags` — SHAPE_MISMATCH (breaks-gameplay) + +Two defects: (a) same [FromBody]+form 415 rejection; (b) even past that, the client sends REPEATED form fields 'tag' and 'autoTag', while the server reads a single 'Tags' CSV — the actual fields would never bind. Tag saves broken. + +Handler: `DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:98` + +**Fix.** Read form["tag"] and form["autoTag"] as repeated values, join into TagsCsv, return details response. + +##### `PUT playlists/{0}/warning` — VERB_MISMATCH (breaks-gameplay) + +[HttpPost] only -> client PUT gets 405. Additionally the handler is an explicit no-op ('field not persisted yet') — warningMask/customWarning are never stored, so even after adding PUT the setting is a stub (WarningMask always 0 in the union entry, line 3260 of RoomsController). + +Handler: `DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:122` + +**Fix.** Add [HttpPut], read form warningMask/customWarning, persist to PlaylistEntity columns and surface them in BuildPlaylistUnionEntry. + +##### `PUT playlists/{0}/accessibility` — VERB_MISMATCH (breaks-gameplay) + +[HttpPost] only -> 405 on the client's PUT, and the shared PlaylistAck handler is a no-op — publish/private (accessibility enum) is never persisted; union entry hardcodes Accessibility=1. Publishing a playlist is impossible. + +Handler: `DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:118` + +**Fix.** Add [HttpPut], persist 'accessibility' form int on the entity, emit it in BuildPlaylistUnionEntry. + +##### `PUT playlists/{0}/restrictions` — VERB_MISMATCH (breaks-gameplay) + +[HttpPost] only + no-op stub. Client PUTs supportsJuniors/supportsScreens/supportsTeleportVR/supportsWalkVR booleans; 405 today, discarded after a verb-only fix. + +Handler: `DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:121` + +**Fix.** Add [HttpPut], persist the four booleans, surface in union entry. + +##### `PUT playlists/{0}/levelvoting` — VERB_MISMATCH (breaks-gameplay) + +[HttpPost] only + no-op stub; client PUTs 'supportsLevelVoting'; union entry hardcodes SupportsLevelVoting=false. + +Handler: `DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:120` + +**Fix.** Add [HttpPut], persist supportsLevelVoting, surface in union entry. + +##### `POST api/keepsakes` — SHAPE_MISMATCH (breaks-gameplay) + +Create() reads a legacy DTO {category,eventKey,title,description,imageName,earnedAt}; the 2023 client sends {"RoomId":long,"SubRoomId":long?,"KeepsakeCategory":int} — all three ignored, so the row is stored as category 'event' with a synthetic 'manual:{guid}' key. Response is a full object (ToWire) but the client does Deserialize expecting a BARE JSON GUID string -> throws. Placing keepsakes is fully broken, and the knock-on is that GET api/keepsakes/rooms/{roomId} (which filters category=='room' with room:{id} EventKeys) can never surface a client-placed keepsake. + +Handler: `DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs:117` + +**Fix.** Bind {RoomId,SubRoomId,KeepsakeCategory}, store a real KeepsakeInstance row (guid id, room, subroom, category, placer), and return the bare Guid as the JSON body. + +##### `DELETE api/keepsakes/{keepsakeInstanceId:guid}` — FIXED + +Was: no DELETE route under api/keepsakes at all; a GUID path segment matched no template in KeepsakesController -> 404, so removing a placed keepsake failed. + +**Fixed.** `KeepsakesController.Delete` ([HttpDelete("{keepsakeInstanceId:guid}")]) resolves the guid back to the row whose EventKey encodes it, gates on placer / room creator / accepted co-owner (RoomRoles.Role==0) / admin, deletes the instance plus any collection rows pointing at it, and returns a bodiless 200 — the client's issuing method returns the non-generic `LDGADANDBIO` promise (NCCLEJPIABA.JBKENCNIEPA, NCCLEJPIABA.txt:1544 + `Move rdx, 4`@1698) so no body is read. + +##### `POST api/keepsakes/{keepsakeInstanceId:guid}/collect` — FIXED + +Was: route not registered -> 404, so collecting a keepsake in-room failed. + +**Fixed.** `KeepsakesController.Collect` ([HttpPost("{keepsakeInstanceId:guid}/collect")]) writes a collection row (`Category="collection"`, `EventKey="collect:{roomId}:{instanceGuid:N}"` — no schema change) and returns `{"TotalXp":int,"SocialBoostXp":int}` (DHNBKMHDANK; keys from its Utf8Json formatter, RecNet.Runtime/PKCMBJFBHBO.txt:42,69). TotalXp is the whole award — the client renders `TotalXp - SocialBoostXp` as the base figure (PDFJLLECNBE_NestedType_LKIPMJFEAFK.txt:82-99) — and is priced from the same category `XpValue` that `api/keepsakes/categories` serves. SocialBoostXp is 0 while SocialXpBoostEnabled=false. Re-collecting the same instance is idempotent and awards 0. + +Two supporting corrections in the same controller: `api/keepsakes/categories` now emits the full `PIHCLHIKEPH` id set (0-8, dump.cs:1199524) instead of three entries keyed by DorkNet's internal account/event/room buckets — the client folds the list into a `Dictionary` that every placed instance is looked up in — and `api/keepsakes/rooms/{roomId}` now returns real `CollectionRecords` (`{AccountId, KeepsakeInstanceId, CollectedAt}`, dump.cs:1234642 / CNNAFLNKDCL.txt:48,75,99) scoped to the caller instead of an empty array. + +##### `GET api/keepsakes/events/{progressionEventId}` — SHAPE_MISMATCH (breaks-gameplay) + +Returns {KeepsakeProgressionEventId, Instances:, CollectionRecords:[], KeepsakeProgressionEventIds}. Client KeepsakeProgressionEventInstancesDTO wants Instances AND CollectionRecords as List = [{"RoomId":long,"KeepsakeInstanceIds":[guid]}]. The row objects contain neither key -> RoomId=0 and KeepsakeInstanceIds=null per entry (NRE risk in the event hunt map). + +Handler: `DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs:59` + +**Fix.** Group event keepsake instances by room and emit {Instances:[{RoomId,KeepsakeInstanceIds:[...]}],CollectionRecords:[same shape for the caller's collected ones]}. + +##### `POST api/progressionEvents/collect/{eventId}/{rewardIndex}` — MISSING (breaks-gameplay) + +Route not registered anywhere -> 404. Claiming a reward chest on the progression track fails; client expects the standard GiftDrop object (same DTO as gifts/generate, singular GiftDrop keys per docs/recroom-2023-room-consumables.md). + +**Fix.** Add [HttpPost("api/progressionEvents/collect/{eventId:long}/{rewardIndex:int}")] that increments the player's RewardsCollected and returns a GiftDrop object (reuse the gifts/generate wire builder). + +##### `GET event/{0}/instances` — FIXED + +Was: only /room/{roomId:long}/instances existed; no event/{eventId}/instances was registered anywhere -> 404 and the 'join' instance browser for multi-instance events never populated. Client wants List<{RoomInstanceId,RoomId,SubRoomId,IsFull,CreatedAt,PlayerIds}> — the 2023 Utf8Json reader accepts the camelCase keys the room variant already emits. + +Verb/shape ground truth: `Matchmaking+b__0` (Matchmaking_NestedType_CBMHJMNIHNN.txt:14) formats `"event/{0}/instances"` at :296 with the PlayerEventId ([rdi+16] of the HPIOAGDJHDH it closes over) and news up BNDIAONDFFF with rdx=0 at :124 = HTTPMethods.Get. Return type `FGLDKEJLAKB>` = bare JSON array; PNDCMIMEJLD's getters (PNDCMIMEJLD.txt:3-197) are Int64/Int64/Int64/Boolean/DateTime/List\/String/Int32/Boolean/String, i.e. the same SimpleRoomInstance the room browser returns (Matchmaking.txt:14237 uses an identical BNDIAONDFFF/verb-0 sequence). The trailing SubroomName/PlayerCount/HasModPresent/HashedInstanceId are populated locally on the watch and are not read off the wire. + +**Fixed.** `[HttpGet("/event/{eventId:long}/instances")]` now lives on `PlayerEventsController.EventInstances` (the route is a matchmaking-host-root path but the data is a player-event one): it resolves the event's RoomId plus the SubRoomId persisted in the event's extras blob, then enumerates PlayerPresenceService instances for that room (filtered to the sub-room when the event names one) and merges in the caller's own PrivateInstances rows, emitting the same six camelCase keys as `room/{id}/instances`. `isFull` is computed from the instance's MaxCapacity vs its presenced player count. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs` (`EventInstances`) + +##### `GET api/playerevents/v1/{0}` — SHAPE_MISMATCH (degraded) + +Verb OK (HttpGet v1/{eventId} shares GetOne with v2). ToWire (lines 262-274) emits only {PlayerEventId,Name,Description,StartTime,EndTime,CreatorPlayerId,AttendeeCount,RoomId,ImageName,Accessibility}. Missing vs HPIOAGDJHDH: SubRoomId, ClubId, IsMultiInstance, SupportMultiInstanceRoomChat, DefaultBroadcastPermissions, CanRequestBroadcastPermissions, BroadcastingRoomInstanceId (Utf8Json leaves defaults: false/0/null). AttendeeCount hardcoded 0, Accessibility hardcoded 1, ImageName always "". includeDetails=true (MDCBEPJCJPO) never gets a Tags key -> Tags list null on the details page. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:49` + +**Fix.** Extend ToWire with SubRoomId/ClubId/IsMultiInstance/SupportMultiInstanceRoomChat/DefaultBroadcastPermissions/CanRequestBroadcastPermissions/BroadcastingRoomInstanceId (persist them on PlayerEventEntity instead of the PlayerSettings side-table), compute AttendeeCount from PlayerEventResponses, and emit Tags:[{Tag,Type}] when includeDetails=true. + +##### `POST api/playerevents/v1/bulk` — SHAPE_MISMATCH (degraded) + +POST registered; ReadEventIdsAsync (line 627) reads repeated form 'Ids' correctly. Response uses the shared ToWire, so each event is missing the seven HPIOAGDJHDH keys listed for v1/{0} (multi-instance flags, ClubId, etc.) and AttendeeCount is always 0. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:152` + +**Fix.** No route change; fix the shared ToWire shape. + +##### `GET api/playerevents/v1/room/{0}` — SHAPE_MISMATCH (degraded) + +Route and verb OK; array response OK; items use the shared incomplete ToWire (see v1/{0}). + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:87` + +**Fix.** Fix shared ToWire. + +##### `GET api/playerevents/v1/clubs` — SHAPE_MISMATCH (degraded) + +Verb/array OK, but the client's repeated 'id' query params (one per club id) are completely ignored — the handler returns events derived from ALL club members' rooms, not the requested clubs. Items also use the incomplete ToWire, and ClubId is never emitted so the client can't bucket events per club. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:123` + +**Fix.** Read the repeated 'id' query values, filter events by ClubId (requires persisting ClubId on the event entity), and emit ClubId in the wire object. + +##### `GET api/playerevents/v1/search` — SHAPE_MISMATCH (degraded) + +Verb OK; 'query' param handled (line 66). 'sort' (PJBLEKMMACM enum) and 'scheduleFilter' (BJBLPLKMLBE enum) are ignored — results always start-time ordered regardless of the chip the user picks. Items use incomplete ToWire. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:58` + +**Fix.** Honor sort/scheduleFilter query ints; fix shared ToWire. + +##### `GET api/playerevents/v1/searchlive` — SHAPE_MISMATCH (degraded) + +Shares the Search handler. Client DTO CLDDIKOJMAM additionally reads PlayerCount (Int32) and IsFull (Boolean); neither is emitted, so every happening-now event shows 0 players and never-full. Handler also doesn't restrict to currently-live events (only EndsAt > now, includes future). + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:59` + +**Fix.** Give searchlive its own handler filtered to StartsAt <= now < EndsAt, emitting ToWire + PlayerCount (from presence) + IsFull. + +##### `GET api/playerevents/v1/tagfilters` — SHAPE_MISMATCH (degraded) + +Emits PinnedFilters and PopularFilters only (lines 166-170). Client AKCLLEJNFFD also reads TrendingFilters:[String]; missing key deserializes to a null list — NRE risk when the chips UI iterates it. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:165` + +**Fix.** Add TrendingFilters = new[]{...} (can duplicate PopularFilters). + +##### `PUT api/playerevents/v2/{0}/accessibility` — SHAPE_MISMATCH (degraded) + +PUT registered and 'accessibility' form field read, but (a) response is the flat ToWire event, not the PHHAKLPGNGC wrapper {PlayerEvent,Result,TagModifyResult} the client reads -> PlayerEvent=null/Result=0 after save; (b) the value is written to a PlayerSettings side-table (SetEventSettingAsync line 759) while ToWire hardcodes Accessibility=1, so the change never reflects on any read — an effective stub. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:455` + +**Fix.** Persist Accessibility on PlayerEventEntity, return the PHHAKLPGNGC wrapper from all v2 field mutations. + +##### `PUT api/playerevents/v2/{0}/name` — SHAPE_MISMATCH (degraded) + +PUT + 'name' field OK and persisted to Title; response is flat ToWire instead of PHHAKLPGNGC wrapper. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:387` + +**Fix.** Return the wrapper. + +##### `PUT api/playerevents/v2/{0}/description` — SHAPE_MISMATCH (degraded) + +PUT + 'description' field OK; flat ToWire response instead of wrapper. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:401` + +**Fix.** Return the wrapper. + +##### `PUT api/playerevents/v2/{0}/image` — SHAPE_MISMATCH (degraded) + +PUT + 'imageName' read, but value goes to the PlayerSettings side-table while ToWire hardcodes ImageName="" (line 272) — image never persists visibly; flat response instead of wrapper. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:445` + +**Fix.** Persist ImageName on the entity; return wrapper. + +##### `PUT api/playerevents/v2/{0}/room` — SHAPE_MISMATCH (degraded) + +PUT + 'roomId' OK and persisted; client's optional 'subRoomId' form field is ignored (SubRoomId not stored/emitted); flat response instead of wrapper. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:414` + +**Fix.** Read+persist subRoomId; return wrapper. + +##### `PUT api/playerevents/v2/{0}/club` — SHAPE_MISMATCH (degraded) + +PUT registered, but client omits clubId entirely to CLEAR the club and the handler returns 400 missing_club when absent (line 496) — detaching a club is impossible. Value also goes to the side-table (never emitted, ToWire has no ClubId key); flat response instead of wrapper. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:490` + +**Fix.** Treat absent clubId as clear (null), persist ClubId on the entity, emit it in ToWire, return wrapper. + +##### `PUT api/playerevents/v2/{0}/time` — SHAPE_MISMATCH (degraded) + +PUT + startTime/endTime fields OK and persisted; flat ToWire response instead of PHHAKLPGNGC wrapper. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:429` + +**Fix.** Return the wrapper. + +##### `GET playlists/visitedby/me` — STUB (degraded) + +Hardcoded Ok(Array.Empty()) — visits are never tracked, so the recently-visited playlists tab is permanently empty. Documented in-code as a placeholder. + +Handler: `DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs:232` + +**Fix.** Track playlist visits (PlaylistVisitEntity or derive from member-room visits) and return the visited union entries. + +##### `GET api/progressionEvents/record/{progressionEventId}` — SHAPE_MISMATCH (degraded) + +Emits {ProgressionEventId,Xp,ClaimedRewardIndex,PurchasedXpBoostCount,DailyBoostGameplayMinutes,XpBoostExpiresAt}; the 2023 ProgressionEventRecordDTO reads {AccountId,Xp,GameMinutesToday,RewardsCollected,BonusRewardsCollected,XpBoostLastPurchasedAt}. Only Xp overlaps (and it's hardcoded 0) — client sees an all-default record; progression track always shows zero progress/rewards. Route existing at least avoids the 404 dorm-loop trap. + +Handler: `DorkNet.Server/Controllers/API/ProgressionEvents/ProgressionEventsController.cs:85` + +**Fix.** Rename keys to AccountId/Xp/GameMinutesToday/RewardsCollected/BonusRewardsCollected/XpBoostLastPurchasedAt and back Xp/RewardsCollected with real stored values. + +##### `GET api/progressionEvents/{eventId}/xpboosts` — MISSING (degraded) + +Not registered -> 404. XP-boost purchase sheet fails to load; client expects a JSON array of {ProgressionEventPurchasableXpBoostId(guid),Cost,XpMultiplier,XpCap,LookbackDurationTicks,CooldownDurationTicks}. Degraded rather than fatal because event/{id} advertises UsesBoost=false and PurchasableXpBoostId=null, so the UI shouldn't normally open the sheet. + +**Fix.** Add [HttpGet("api/progressionEvents/{eventId:long}/xpboosts")] returning [] (or real boosts if the feature is enabled). + +##### `GET api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp` — MISSING (degraded) + +Not registered -> 404. Boost purchase preview fails; client expects a bare JSON integer. Same mitigation as xpboosts (UsesBoost=false). + +**Fix.** Add [HttpGet("api/progressionEvents/{eventId:long}/xpboosts/{boostId:guid}/previewEarnedXp")] returning Ok(0) or a computed value. + +##### `POST api/playerevents/v1/bulkInvite` — CASING_MISMATCH (cosmetic) + +Verb, request DTO {PlayerEventId,InvitedPlayerIds} and top-level response {FailedInvites,Result} all match. Failed-entry keys are {PlayerId,Error} (line 550) but the client FOOJAOBCLBA reads {InvitedPlayerId:int,Result:int} — only hit when an invited player doesn't exist (entry deserializes as defaults, no crash). Error paths NotFound()/Forbid() return non-JSON bodies the client can't parse. + +Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs:532` + +**Fix.** Change failed entries to new { InvitedPlayerId = rid, Result = } and return {FailedInvites,Result:err} JSON instead of NotFound/Forbid. + +### Inventions, images, video and studio assets + +`inventions-media` + +Audited 40 real client routes against DorkNet. Hard breaks: POST api/inventions/v4/addversion 400s on every call (server binds "BlobName", client sends "inventionDataFilename"); POST api/inventions/v1/settags 400s (server binds tag lists as strings and wrong key name); api/inventions/v1/dormskinsfromids returns invention objects where the client deserializes List; POST api/images/v1/cheer and sendlink 400 (wrong request keys); modifyaccessibility silently forces every photo private; api/images/v5/cheered/bulk has no GET so cheer-states 404 in the common <100-id case; GET api/images/v6/{id} and POST api/images/v1/{id}/report are unrouted; showcase/{accountId} and remote-run/push-to-studio are missing entirely. Systemic shape gaps: the shared invention wire builder (InventionsController.ToWire) omits the 2023 client's Accessibility and IsCertifiedInvention keys and version objects omit ChipsCost/CloudVariablesCost/BlobHash; the GET-mutation family (update/delete/publish/unpublish/updateprice/addversion/cheer) returns bare invention objects instead of the {Status,Invention,InventionVersion} envelope; the shared image builder (ImagesController.BuildImageInfo) omits SavedImageId/SavedImageType/ClubId so photo ids read as 0 in feeds; every bulk endpoint's POST fallback (>=100 ids) expects JSON but the client posts form fields (415). updateprice is GET-only but the client POSTs (405). unity_assets/{id}/{t}/{v} serves raw bundle bytes where the traced rooms-service method expects a JSON descriptor. + +**Client-side notes.** TRANSPORT CONVENTIONS (grounded): All RecNet.Runtime requests go through builder BNDIAONDFFF..ctor(HTTPMethods verb, GJDLNNLKDIJ host, string route) at RVA 0x30036A0 (recnet-runtime-decomp/BNDIAONDFFF.cs:194); verb is BestHTTP HTTPMethods (0=GET, 2=POST) moved into rdx immediately before the ctor call; host enum is almost always 1 (main API). AFGEDDANEKP(name,value) fields become the QUERY STRING on GET and form/body fields on POST (Build method FGHNOKLDOKO assembles \"?\"/\"&\" — BNDIAONDFFF.txt:3246-3251). FJLLPHFOOJJ(string) sets a raw JSON body. BPHHLAIILHP adds a multipart file part. Bulk endpoints pick the verb at runtime via ALHIJCJOLCB.JIECAFGCODK(count, threshold=100): GET when the id list is short, POST when >=100 (ALHIJCJOLCB.cs:110; e.g. KLJOGJHBONK_NestedType_HKPGODOKOJP.txt:222-224 'Move rsi,2 / Compare rax,100 / cmovl esi,r14d') — DorkNet must accept BOTH verbs on every bulk route. RESPONSE KEY CASING: generated per-DTO serializers cache three variants of every key (PascalCase/camelCase/lowercase) in their .ctor (e.g. BLADEIPEPOH.txt), so responses may use any of the three casings; PascalCase documented. REQUEST BODY CASING IS MIXED AND EXACT (JsonUtility uses raw field names): ModifyTagsRequest/UpdatePriceRequest/ReportRequest/CheerRequest are PascalCase, but NewInventionRequestDTO/AddVersionInventionRequestDTO are camelCase — matches memory note 'watch JSON key casing varies per DTO'. Mutations on inventions v1/update, v1/delete, v3/publish, v1/unpublish are genuinely GET-with-query in this client. DateTime wire format not verified from binary (reader parses a JSON string; format UNKNOWN — DorkNet's current ISO output is presumably accepted since these pages work). SERVER GAPS FOUND vs the server's reflected route table: (1) GET api/images/v6/{id} not registered (only bare api/images/v6) — photo tagged-players lookup will 404; (2) POST api/images/v1/{id}/report missing — photo reporting 404s; (3) api/images/v5/cheered/bulk registered POST-only but client sends GET when <100 ids (the common case); (4) api/inventions/v1/updateprice registered GET-only but client POSTs JSON UpdatePriceRequest; (5) api/inventions/v1/fulllineageowner and v1/fromcreators registered GET-only — fine until an id list reaches 100, then client switches to POST; (6) showcase/{accountId} (rooms-service client, returns JSON array of room ids) not registered; (7) unity_assets/{name}/{target}/{version} (returns {UnityAssetId,Target,Version,Filename,Hash}) not registered under that path; (8) remote-run/push-to-studio not registered (Rec Room Studio dev flow — optional for normal gameplay). Non-routes in the group: 'api/images/' and 'api/inventions/' are format prefixes; 'inventionsbycreators/{0}/{1}/{2}' is a memoization cache key; 'image/{0}' is a rec.net share deeplink. Enum value tables extracted for server use: CFHBHALNFKC (invention op Status, 0=Success... includes AlreadyCheered/InvalidPrice etc., CFHBHALNFKC.cs), OFLPPEFGGOP (settags Result), JJDFPDDKLMO (permission, gap-skipping 0/10/15/20/40/60/80/100), GGEKBCHLINO (invention accessibility 0=Private,1=Public,2=Unlisted), KIKIEHKBHNM (tag type), EIHGOODFAOG (report category, -1..4), OMMBGJMJJPN (creatorAccountRole), GCEMFHEDHPB (image accessibility), AOJNEMGCDPM (image type 0-7), GGOOKOOBDBL (sort), JMLFDGEMALD (room photo filter). Decompiled RecNet.Runtime sources live in C:/tmp/recnet-runtime-decomp (NOT in C:/tmp/recroom-2023-03-21-decompiled, which lacks RecNet.Runtime types). + +#### Endpoints + +| Verb | Route | Request | Response | +|---|---|---|---| +| POST | `api/images/v1/cheer` | JSON body: {"SavedImageId": Int64, "Cheer": Boolean} | response body not consumed | +| POST | `api/images/v1/deletesaved` | JSON body: {"ImageName": String} | response body not consumed | +| GET | `api/images/v1/listsaved` | none (auth user implied) | OJOIJKLINID object: {"Images": List} — projected client-side to List of image blob names | +| POST | `api/images/v1/modifyaccessibility` | JSON body: {"ImageName": String, "Accessibility": Int32 (0=Private,1=Public,2=FriendsOnly)} | response body not consumed (client awaits status only) | +| POST | `api/images/v1/sendlink` | JSON body: {"ImageName": String} | response body not consumed | +| GET | `api/images/v1/slideshow` | none | {"ValidTill":DateTime string,"Images":[{"SavedImageId":Int64,"ImageName":String,"Username":String,"PlayerId":Int32,"RoomName":String,"RoomId":Int64?}]} | +| POST | `api/images/v1/{id}/report` | none (empty body; id in path) | response body not consumed (fire-and-forget KDOPJCNKOOK) | +| GET | `api/images/v2/named` | none | JSON array: [{"FriendlyImageName":String,"ImageName":String,"StartTime":DateTime string,"EndTime":DateTime string}] | +| GET | `api/images/v4/room/{roomId}?sort&filter&take&skip` | path roomId (Int64); query fields "sort" Int32 (GGOOKOOBDBL), "filter" Int32 (JMLFDGEMALD 0=VisibleToPlayer,1=PublicOnly,2=PublicLocalPlayerTagged), "take" Int32, "skip" Int32 | JSON array of ICOFKEGOGOD (13 keys as in v5/bulk) | +| POST | `api/images/v4/uploadsaved` | multipart/form-data: file part name "image", filename "file.bin" (JPEG bytes, quality 80); form field "imgMeta" = JsonUtility JSON of SavedImageMetaDTO {"playerIds":List,"sa | DABHPBPDBBK object: {"ImageName": String} — projected to string | +| GET | `api/images/v5/bulk` | field "ids" = list of Int64; sent as GET query when count<100, as POST form when >=100 (ALHIJCJOLCB.JIECAFGCODK(count,100)) — server must accept BOTH verbs | JSON array of ICOFKEGOGOD: [{"SavedImageId":Int64,"ImageName":String,"PlayerId":Int32,"RoomId":Int64?,"PlayerEventId":Int64?,"ClubId":Int64?,"Description":String,"Accessibility":Int32,"AccessibilityLocked":Boolean,"Saved | +| GET | `api/images/v5/cheered/bulk` | field "id" = list of Int64 saved-image ids; GET query when count<100 else POST form (JIECAFGCODK) | JSON array: [{"SavedImageId":Int64,"IsCheered":Boolean}] | +| GET | `api/images/v5/player/{playerId}?sort={int}` | path playerId (Int32 account id), query field "sort" Int32 (GGOOKOOBDBL 0=CreatedAt_Desc,1=CheerCount_Desc,2=CreatedAt_Asc) | JSON array of ICOFKEGOGOD (13 keys as in v5/bulk) | +| GET | `api/images/v6/{id}` | path id (Int64), no query | LGLCPNPJCEC object: {"Id":Int64,"ImageName":String,"PlayerId":Int32,"RoomId":Int64?,"PlayerEventId":Int64?,"Accessibility":Int32,"AccessibilityLocked":Boolean,"Type":Int32 (AOJNEMGCDPM),"CreatedAt":DateTime string,"Tagge | +| GET | `api/images/v6?name={imageName}` | query: name=String (image blob name) | single LGLCPNPJCEC object (same 12 keys as api/images/v6/{id}) | +| POST | `api/inventions/v1/cheer` | JSON body CheerRequest: {"InventionId":Int64,"Cheer":Boolean} | BDNCJIPHHOK | +| GET | `api/inventions/v1/delete?inventionId={id}` | query: inventionId=Int64 (mutation via GET) | BDNCJIPHHOK (Status/Invention/InventionVersion as above) | +| GET | `api/inventions/v1/details?inventionId={id}` | query: inventionId=Int64 | OIABGAKJABE: {"Tags":[{"Tag":String,"Type":Int32 (KIKIEHKBHNM 0=General,1=Auto,2=AGOnly,3=Banned)}]} | +| GET | `api/inventions/v1/dormskinsfromids` | field "ids" = list of Int64 invention ids; GET query when <100 else POST form (JIECAFGCODK) | JSON array of Int64 (the subset of ids that are dorm skins) | +| GET | `api/inventions/v1/featured` | none | JSON array of IFJONDCAKKM | +| GET | `api/inventions/v1/featureddormskins` | none | JSON array of IFJONDCAKKM | +| GET | `api/inventions/v1/fromcreators` | field "id" = list of Int32 creator account ids (GET query when <100 else POST via JIECAFGCODK), "skip"=Int32, "take"=Int32 | JSON array of IFJONDCAKKM (22-key objects) | +| GET | `api/inventions/v1/fulllineageowner` | field "id" = list of Int64 invention ids; GET query when <100 else POST (generic list AFGEDDANEKP overload) | bare JSON Boolean (true if player owns full lineage of all ids) | +| GET | `api/inventions/v1/personaldetails/{id}` | path id=Int64 | CEAFHBOOBKL: {"IsCheering":Boolean} | +| POST | `api/inventions/v1/report` | JSON body ReportRequest: {"InventionId":Int64,"Details":String,"ReportCategory":Int32 (EIHGOODFAOG -1=Unknown,0=CoC_Discriminatory,1=CoC_Sexual,2=CoC_Trolling,3=Misleading,4=Other) | PHMHCPEMABG: {"Success":Boolean,"Message":String} | +| GET | `api/inventions/v1/room?id={roomId}` | query: id=Int64 room id | JSON array of IFJONDCAKKM (inventions used/placed in the room) | +| POST | `api/inventions/v1/settags` | JSON body ModifyTagsRequest: {"InventionId":Int64,"AutoTags":[String],"CustomTags":[String]} (PascalCase, JsonUtility field names) | PNGLFHEAJIH: {"Result":Int32 (OFLPPEFGGOP 0=Success,1=TooManyTags,2=TagUseRestricted,3=InvalidTag,4=InappropriateTag,5=TagTooLong,6=TagNotFound,7=TagAlreadyExists,8=NoChange,9=TagRepeated,10=LacksPermission,11=RoomDoesNo | +| GET | `api/inventions/v1/tagfilters` | none (enclosing method takes an IFFEGNLLPDI context enum but no field is attached before send) | AKCLLEJNFFD: {"PinnedFilters":[String],"PopularFilters":[String],"TrendingFilters":[String]} — projected to one List | +| GET | `api/inventions/v1/toptoday` | none | JSON array of IFJONDCAKKM | +| GET | `api/inventions/v1/unpublish?inventionId={id}` | query: inventionId=Int64 | BDNCJIPHHOK | +| GET | `api/inventions/v1/update` | query: inventionId=Int64 plus exactly one of name=String \| description=String \| imgName=String \| permission=Int32 (JJDFPDDKLMO 0=Unassigned,10=LimitedOneUseOnly,15=DisallowKeyLo | BDNCJIPHHOK: {"Status":Int32 (CFHBHALNFKC 0=Success,1=InvalidParameters,...26=PriceCannotBeChanged...),"Invention":IFJONDCAKKM object\|null,"InventionVersion":PLIKEBBPJGI object\|null} | +| POST | `api/inventions/v1/updateprice` | JSON body UpdatePriceRequest: {"InventionId":Int64,"Price":Int32} | BDNCJIPHHOK | +| GET | `api/inventions/v1/version?inventionId={id}&version={n}` | query: inventionId=Int64, version=Int32 (URL preformatted by client) | single PLIKEBBPJGI object (9 keys as above) | +| GET | `api/inventions/v1/versions?inventionId={id}` | query: inventionId=Int64 | JSON array of PLIKEBBPJGI: [{"InventionId":Int64,"ReplicationId":String (guid),"VersionNumber":Int32,"InstantiationCost":Int32,"LightsCost":Int32,"ChipsCost":Int32,"CloudVariablesCost":Int32,"BlobName":String,"BlobHash": | +| GET | `api/inventions/v1?inventionId={id}` | query: inventionId=Int64 (URL preformatted "{0}v1?inventionId={1}") | single IFJONDCAKKM object (22 keys) | +| GET | `api/inventions/v2/batch` | field "id" = list of Int64 invention ids; GET query when <100 else POST (JIECAFGCODK) | JSON array of IFJONDCAKKM | +| GET | `api/inventions/v2/mine` | none (auth user) | JSON array of IFJONDCAKKM | +| GET | `api/inventions/v2/search?value&skip&take` | query: value=String (search text), skip=Int32 (default 0), take=Int32 (default 100) | JSON array of IFJONDCAKKM: [{"InventionId":Int64,"ReplicationId":String,"CreatorPlayerId":Int32,"Name":String,"Description":String,"ImageName":String,"CurrentVersionNumber":Int32,"Accessibility":Int32 (GGEKBCHLINO),"Modi | +| GET | `api/inventions/v3/publish?inventionId&permissionLevel&accessibility&price` | query: inventionId=Int64, permissionLevel=Int32 (JJDFPDDKLMO), accessibility=Int32 (IFJONDCAKKM/GGEKBCHLINO 0=Private,1=Public,2=Unlisted), price=Int32 (nullable, only when set) | BDNCJIPHHOK | +| POST | `api/inventions/v4/addversion` | JSON body AddVersionInventionRequestDTO (camelCase): {"inventionId":Int64,"instantiationCost":Int32,"lightsCost":Int32,"chipsCost":Int32,"cloudVariablesCost":Int32,"aiCost":Int32," | BDNCJIPHHOK | +| POST | `api/inventions/v6/save` | JSON body NewInventionRequestDTO (camelCase JsonUtility field names): {"name":String,"description":String,"imageName":String,"instantiationCost":Int32,"lightsCost":Int32,"chipsCost | BDNCJIPHHOK | +| POST | `remote-run/push-to-studio` | JSON body FNAGBPCAGJD: {"SessionId":String,"RoomId":Int64,"SubRoomId":Int64,"UnityAssetId":String,"RoomData":{"Filename":String,"Hash":String,"OwnershipProof":String},"SubRoomData" | CEELGOLBHIL: {"SessionId":String,"RoomId":Int64?,"SubRoomId":Int64?,"UnityAssetId":String,"RoomDataFilename":String,"RoomDataHash":String,"SubRoomDataFilename":String,"SubRoomDataHash":String} | +| GET | `showcase/{0}` | path {0}=Int32 account id; no query. Issued on the rooms-service client (NLDBPDCNNCF, same client as rooms/, rooms/magic_door, photon_access_token) | bare JSON array of Int64 room ids | +| GET | `unity_assets/{0}/{1}/{2}` | path: {0}=URL-encoded unity asset name/id (String), {1}=target (Byte, platform), {2}=version (Int32); rooms-service client | PPBIFMDLDCB: {"UnityAssetId":String,"Target":Byte,"Version":Int32,"Filename":String,"Hash":String} | +| GET | `video/` | URL = {media base from PAPLNIPKAMG config} + "video/" + {filename}; plain file fetch (mp4 bytes), no query/body | raw video bytes (no JSON) | + +#### Not HTTP routes + +These literals look like paths but are cache keys, deeplinks or MIME types: + +- `api/images/` — n/a +- `api/inventions/` — n/a +- `image/{0}` — Sharing a photo (generates web link) +- `inventionsbycreators/{0}/{1}/{2}` — memoization key for GetInventionsByCreators + +#### Defects + +##### `POST api/images/v1/modifyaccessibility` — SHAPE_MISMATCH (breaks-gameplay) + +Client body is {"ImageName":String,"Accessibility":Int32} (0=Private,1=Public,2=FriendsOnly). Server's ModifyAccessibilityRequest has no Accessibility member — it reads bool IsPublic, which the client never sends, so isPublic evaluates to body.IsPublic default = false on EVERY request. Result: choosing "Public" in the photo privacy UI silently sets the photo private; the toggle can only ever privatize. + +Handler: `DorkNet.Server\Controllers\API\Images\ImagesController.cs:626` + +**Fix.** Add `public int? Accessibility { get; set; }` to ModifyAccessibilityRequest and compute isPublic = body.Accessibility is int a ? a == 1 : (body.IsPublic/form fallback). Treat 2 (FriendsOnly) as non-public until a 3-state column exists. + +##### `POST api/inventions/v1/settags` — SHAPE_MISMATCH (breaks-gameplay) + +Client body is {"InventionId":Int64,"AutoTags":[String],"CustomTags":[String]}. Server binds SetTagsRequest(long InventionId, string? AutoTags, string? PlayerAddedTags): AutoTags is a JSON ARRAY bound to string → System.Text.Json model-binding fails → automatic 400; and the second list key is CustomTags, not PlayerAddedTags, so it would be dropped anyway. Response is also wrong: bare ToWire invention instead of PNGLFHEAJIH {"Result":Int32,"Tags":[String]}. Net effect: setting tags in the publish/edit flow always fails. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:669` + +**Fix.** Change SetTagsRequest to (long InventionId, List? AutoTags, List? CustomTags); store the union in TagsCsv; return Ok(new { Result = 0, Tags = combinedList }). + +##### `POST api/inventions/v4/addversion` — SHAPE_MISMATCH (breaks-gameplay) + +Client body keys are camelCase {"inventionId","instantiationCost","lightsCost","chipsCost","cloudVariablesCost","aiCost","creationRoomId","inventionDataFilename","referencedInventions"}. Server's AddVersionRequest requires "BlobName" — a key the client NEVER sends (its filename key is inventionDataFilename) — so BlobName binds null and the handler returns 400 "missing BlobName" on every call: saving a new version of any existing invention always fails (maker-pen re-save of an invention is dead). Response is also a bare ToWire, not the BDNCJIPHHOK envelope. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:512` + +**Fix.** Add InventionDataFilename (and ChipsCost/CloudVariablesCost/AiCost/CreationRoomId/ReferencedInventions) to the request; use InventionDataFilename ?? BlobName as the blob; return {Status=0, Invention=ToWire(inv), InventionVersion=ToVersionWire(newVersion)}. + +##### `POST api/images/v1/sendlink` — SHAPE_MISMATCH (degraded) + +Client body is only {"ImageName":String} ('send ME a link'). Server's SendLinkRequest has PhotoId/ImageId/RecipientIds and no ImageName, so photoId stays 0 and the handler returns 400 BadRequest — the share action's promise rejects every time. + +Handler: `DorkNet.Server\Controllers\API\Images\ImagesController.cs:701` + +**Fix.** Add ImageName to SendLinkRequest; when photoId==0 resolve the photo by BlobName (scoped to caller), and when RecipientIds is empty deliver the link to the calling player (self-DM / notification), returning 200. + +##### `POST api/images/v1/cheer` — SHAPE_MISMATCH (degraded) + +Client body is {"SavedImageId":Int64,"Cheer":Boolean}. Server's CheerImageRequest only has ImageId/PhotoId, so photoId=0 → 400 on every request: photo cheering never works. Even with the id fixed, the Cheer=false (uncheer) case is unsupported — the handler only adds cheers. + +Handler: `DorkNet.Server\Controllers\API\Images\ImagesController.cs:568` + +**Fix.** Add SavedImageId (long) and Cheer (bool, default true) to CheerImageRequest; use SavedImageId as photoId; when Cheer==false delete the CheerEntity row and decrement Photos.CheerCount. + +##### `GET api/images/v6/{id}` — MISSING (degraded) + +Only bare GET api/images/v6 (query ?name=) is registered. api/images/v6/123 falls into the [HttpGet("api/images/{*path}", Order=100)] byte catch-all, which rejects "123" as a non-image filename → 404. The photo-detail tagged-players lookup (HLANOFILAEO) fails on every photo. + +**Fix.** Add [HttpGet("api/images/v6/{id:long}")] to ImagesController resolving db.Photos by Id (public or owned) and returning BuildImageInfo(photo, photo.UploaderPlayerId) — BuildImageInfo already carries all 12 LGLCPNPJCEC keys (Id, ImageName, PlayerId, RoomId, PlayerEventId, Accessibility, AccessibilityLocked, Type, CreatedAt, TaggedPlayerIds, CheerCount, CommentCount). + +**Status: FIXED.** `ImagesController.ImageByIdV6` (`[HttpGet("api/images/v6/{id:long}")]`, anonymous, public-or-owner) returns a single BuildImageInfo object. Key list re-confirmed against the generated serializer `IBILPLGNAJE.txt:819-1078`. + +##### `GET api/images/v5/bulk` — SHAPE_MISMATCH (degraded) + +Two defects. (1) Response: items are BuildImageInfo objects but the client deserializes ICOFKEGOGOD whose keys are SavedImageId / SavedImageType / ClubId / Description — the builder sends Id and Type instead of SavedImageId and SavedImageType and has no ClubId, so the client's photo id reads as default 0 (or the strict reader throws on the missing non-nullable Int64 — throw-vs-default UNKNOWN); every downstream per-photo action (cheer, detail, delete) then targets id 0. (2) POST fallback (>=100 ids): client posts FORM field "ids", but the action binds [FromBody] JSON → ASP.NET returns 415 for form content. + +Handler: `DorkNet.Server\Controllers\API\Images\ImagesController.cs:283` + +**Fix.** Add SavedImageId = p.Id, SavedImageType = 0, ClubId = (long?)null to BuildImageInfo (keep existing aliases). For POST, read Request.Form["ids"] when HasFormContentType instead of relying on [FromBody]. + +##### `GET (POST when >=100 ids) api/images/v5/cheered/bulk` — VERB_MISMATCH (degraded) + +Registered POST-only. The client sends GET with query field "id" whenever the id list is <100 (the overwhelmingly common case) → the request falls into the api/images/{*path} catch-all and 404s, so photo cheer-state (feed hearts) never loads. The POST variant is also broken for this client: it binds [FromBody] JSON {SavedImageIds} but the client posts form field "id" → 415. Response shape itself is correct ([{SavedImageId, IsCheered}]). + +Handler: `DorkNet.Server\Controllers\API\Images\ImagesController.cs:539` + +**Fix.** Add [HttpGet("api/images/v5/cheered/bulk")] (and v4) parsing ids from Request.Query["id"] (comma-split all values); on POST also accept Request.Form["id"]. + +**Status: FIXED.** `ImagesController.CheeredBulk` is now registered GET+POST on v4 and v5 and takes no bound parameter at all — ids come from `ReadCheerBulkIdsAsync`, which reads query values, then form values when the body is form-encoded, then a JSON body (array or object) otherwise. Dropping `[FromBody]` is what removes the 415 on the >=100-id form POST. + +##### `GET api/images/v1/slideshow` — SHAPE_MISMATCH (degraded) + +Top level {ValidTill, Images} is right, but each Images item must be NBNFGGCGCLP {SavedImageId, ImageName, Username, PlayerId, RoomName, RoomId}. BuildImageInfo supplies ImageName/PlayerId/RoomId but has NO SavedImageId (sends Id), NO Username, NO RoomName — the slideshow reader gets default 0/null for those (or throws on the non-nullable SavedImageId — UNKNOWN), so slideshow entries lose attribution or fail to render. + +Handler: `DorkNet.Server\Controllers\API\Images\ImagesController.cs:338` + +**Fix.** Build a slideshow-specific projection: join db.Players on UploaderPlayerId for Username and db.Rooms on RoomId for RoomName, and emit SavedImageId = p.Id. + +##### `GET api/images/v5/player/{playerId}?sort={int}` — SHAPE_MISMATCH (degraded) + +Route+verb exist and int sort binds (ParseSort accepts numerics). Two defects: (1) items are ICOFKEGOGOD → same missing SavedImageId/SavedImageType/ClubId keys as v5/bulk (shared BuildImageInfo); (2) client sort enum 2 = CreatedAt_Asc but ParseSort maps 2 → ViewCount-desc, so the 'oldest first' sort returns the wrong order (cosmetic). + +Handler: `DorkNet.Server\Controllers\API\Images\ImagesController.cs:408` + +**Fix.** Fix BuildImageInfo keys (see v5/bulk) and map sort==2 to OrderBy(p => p.CreatedAt) ascending. + +##### `GET api/images/v4/room/{roomId}?sort&filter&take&skip` — SHAPE_MISMATCH (degraded) + +Route/verb/params exist (sort+filter bind as strings, ints parse; take/skip honored). Same two defects as the player list: ICOFKEGOGOD items miss SavedImageId/SavedImageType/ClubId (shared BuildImageInfo), and sort=2 (CreatedAt_Asc) maps to ViewCount-desc. filter is intentionally ignored (always PublicOnly semantics) — that drops filter=0 VisibleToPlayer (own private/tagged photos in the room list): minor behavioral gap. + +Handler: `DorkNet.Server\Controllers\API\Images\ImagesController.cs:440` + +**Fix.** Same BuildImageInfo key additions + sort==2 ascending; optionally honor filter=0 by including the caller's own private photos. + +##### `POST api/images/v1/{id}/report` — MISSING (degraded) + +No route matches; the request lands in the api/images/{*path} catch-all and 404s. The client fires-and-forgets so the UI doesn't visibly break, but every photo report is silently lost — a moderation-integrity defect on a multi-tenant server. + +**Fix.** Add [HttpPost("api/images/v1/{id:long}/report")] [Authorize] that inserts a ReportEntity (ReporterPlayerId = caller, TargetPhotoId = id, TargetPlayerId = photo.UploaderPlayerId) and returns 200 with any body. + +**Status: FIXED.** `ImagesController.ReportPhoto`. ReportEntity has no TargetPhotoId column and this round adds no schema, so the photo id + blob name ride in `Message` as `[photo {id} {blobName}] …` (the same convention `ClubService.AddClubReportAsync` uses for `[club {id}]`); TargetPlayerId = uploader, RoomId = the photo's room, Category = 5 (Other — the wire carries no category). Adding a `TargetPhotoId` column later would let the admin queue link straight to the image. + +##### `GET api/inventions/v1/update` — SHAPE_MISMATCH (degraded) + +Route, GET verb, and all four one-of query params (name/description/imgName/permission) bind and persist correctly. But the response is a bare ToWire invention object; the client deserializes BDNCJIPHHOK {"Status":Int32,"Invention":obj,"InventionVersion":obj} — none of those keys exist in the reply, so Status reads default (or the strict reader throws — UNKNOWN) and Invention is null, leaving the edit UI without the updated entity. Additionally the Invention payload itself is missing keys (see the ToWire finding). + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:545` + +**Fix.** Return Ok(new { Status = 0, Invention = ToWire(inv), InventionVersion = (object?)null }) — same envelope SaveV4 already uses at line 499. + +##### `GET (POST when >=100 ids) api/inventions/v1/dormskinsfromids` — SHAPE_MISMATCH (degraded) + +GET route exists and the query parser picks up the "ids" field, but it is aliased onto the generic batch handler which returns an ARRAY OF INVENTION OBJECTS; the client deserializes List (the subset of ids that are dorm skins) → object-where-number-expected breaks the reader, so dorm-skin filtering of owned/featured inventions fails. The POST alias (line 135) binds [FromBody] JSON while the client posts form "ids" → 415. There is also no dorm-skin flag on InventionEntity to compute the real subset. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:143` + +**Fix.** Give dormskinsfromids its own handler returning long[]: add an IsDormSkin flag (or tag convention) to InventionEntity and return the requested ids whose inventions match; accept form "ids" on POST. Returning [] is shape-safe interim behavior but disables dorm skins. + +##### `GET api/inventions/v1/details?inventionId={id}` — SHAPE_MISMATCH (degraded) + +Route+verb+param OK, but response is {Invention, Versions}; the client deserializes OIABGAKJABE {"Tags":[{"Tag":String,"Type":Int32}]}. No Tags key → tags list null/empty (or reader throw — UNKNOWN); the invention detail page's tag display is dead. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:370` + +**Fix.** Return Ok(new { Tags = inv.TagsCsv.Split(',', RemoveEmpty).Select(t => new { Tag = t, Type = 0 }) }) — extra keys are harmless, missing Tags is not. + +##### `GET api/inventions/v1/versions?inventionId={id}` — SHAPE_MISMATCH (degraded) + +Route OK; each item must be PLIKEBBPJGI with 9 keys but ToVersionWire emits only 6: ChipsCost (Int32), CloudVariablesCost (Int32), and BlobHash (String) are missing. The two non-nullable ints read as 0 (or throw — UNKNOWN); a missing BlobHash risks failing the client's blob integrity/download step for invention data. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:401` + +**Fix.** Add ChipsCost/CloudVariablesCost/BlobHash columns to InventionVersionEntity (populate hash at upload time; costs from the save/addversion request's chipsCost/cloudVariablesCost) and emit all three in ToVersionWire (BlobHash may be computed on demand from stored bytes as a stopgap). + +##### `GET api/inventions/v1/version?inventionId={id}&version={n}` — SHAPE_MISMATCH (degraded) + +Route and both query params OK; single object uses the same ToVersionWire missing ChipsCost/CloudVariablesCost/BlobHash. This is the pre-download version resolve, so a hash-checking client path would fail here. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:411` + +**Fix.** Same ToVersionWire fix as /versions. + +##### `GET api/inventions/v1/delete?inventionId={id}` — SHAPE_MISMATCH (degraded) + +GET mutation exists and soft-deletes correctly, but returns bare ToWire instead of the BDNCJIPHHOK envelope {Status,Invention,InventionVersion} the client deserializes. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:594` + +**Fix.** Wrap: Ok(new { Status = 0, Invention = ToWire(inv), InventionVersion = (object?)null }). + +##### `GET api/inventions/v3/publish?inventionId&permissionLevel&accessibility&price` — SHAPE_MISMATCH (degraded) + +Route exists but reads only inventionId+permissionLevel: the accessibility query param (0=Private,1=Public,2=Unlisted) and nullable price are silently ignored — publishing as Unlisted/Private still publishes fully public, and paid publishes lose their price. Response is bare ToWire, not the BDNCJIPHHOK envelope. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:610` + +**Fix.** Bind [FromQuery] int accessibility and [FromQuery] int? price; persist price and an Accessibility column (add to InventionEntity); wrap the response in {Status,Invention,InventionVersion}. + +##### `GET api/inventions/v1/unpublish?inventionId={id}` — SHAPE_MISMATCH (degraded) + +Works functionally; response is bare ToWire instead of the BDNCJIPHHOK envelope. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:630` + +**Fix.** Wrap response in {Status=0, Invention=ToWire(inv), InventionVersion=null}. + +##### `POST api/inventions/v1/updateprice` — VERB_MISMATCH (degraded) + +Server registers GET-with-query only; the 2023 client POSTs JSON body {"InventionId":Int64,"Price":Int32} → ASP.NET answers 405 Method Not Allowed, so changing the price of a published paid invention always fails. Response would also need the BDNCJIPHHOK envelope. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:578` + +**Fix.** Add a [HttpPost("api/inventions/v1/updateprice")] overload binding {InventionId, Price} from JSON (keep the GET for older branches) and return {Status=0, Invention=ToWire(inv), InventionVersion=null}. + +##### `GET api/inventions/v2/search?value&skip&take` — SHAPE_MISMATCH (degraded) + +Route+verb+value param OK; skip/take are not bound (results hard-capped at 50, skip ignored) so store-search pagination repeats the same page. Items are IFJONDCAKKM and inherit the ToWire missing-keys defect (Accessibility, IsCertifiedInvention — see the featured/toptoday finding for details). + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:84` + +**Fix.** Bind [FromQuery] int skip = 0, int take = 100 and apply Skip/Take; fix ToWire keys. + +##### `POST api/inventions/v1/cheer` — SHAPE_MISMATCH (degraded) + +Client body is {"InventionId":Int64,"Cheer":Boolean}. Server's CheerRequest has only InventionId — Cheer is ignored, so uncheering (Cheer=false) still leaves the cheer in place. Response is {Id, CheerCount}, not the expected BDNCJIPHHOK envelope (Status/Invention). + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:687` + +**Fix.** Add bool Cheer to the record; on false remove the CheerEntity and decrement CheerCount; return {Status=0, Invention=ToWire(inv), InventionVersion=null}. + +##### `GET (POST when >=100 ids) api/inventions/v1/fromcreators` — SHAPE_MISMATCH (degraded) + +GET exists but the id parser SelectMany's over EVERY query field, so the client's "skip" and "take" values are ingested as creator ids (e.g. take=100 adds player 100's inventions to the shelf), and skip/take paging is not honored. No POST registration for the >=100 case (405). Items inherit the ToWire missing-keys defect. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:169` + +**Fix.** Parse only the "id" query key; bind skip/take and apply them; add a POST accepting form fields. + +##### `GET api/inventions/v1/featured` — SHAPE_MISMATCH (degraded) + +Route exists (shared 'Popular' handler). Systemic response defect for ALL IFJONDCAKKM-list endpoints: ToWire omits two of the 22 keys the 2023 deserializer reads — Accessibility (Int32, 0=Private/1=Public/2=Unlisted; server sends IsPublished bool under a different key instead) and IsCertifiedInvention (Boolean). Missing Accessibility defaults to 0=Private on the client (or the strict reader throws — UNKNOWN), so every published store invention can present as Private in the 2023 UI. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:43` + +**Fix.** Add Accessibility = i.IsPublished ? 1 : 0 (or a real column) and IsCertifiedInvention = false to ToWire. One change fixes featured/featureddormskins/toptoday/mine/search/batch/fromcreators/room/v1-single and every envelope's Invention payload. + +##### `GET api/inventions/v1/toptoday` — SHAPE_MISMATCH (degraded) + +Exists but aliases the same 'Popular' query as featured (identical content on both shelves — not actually 'today') and inherits the ToWire missing Accessibility/IsCertifiedInvention keys. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:45` + +**Fix.** Fix ToWire; optionally scope toptoday to a 24h cheer window for shelf variety. + +##### `GET api/inventions/v1/featureddormskins` — SHAPE_MISMATCH (degraded) + +Exists but returns generic popular inventions, not dorm skins (no dorm-skin flag exists), and inherits the ToWire key gaps. Combined with the broken dormskinsfromids shape, the dorm-skin store surface is effectively non-functional. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:44` + +**Fix.** Add IsDormSkin to InventionEntity, filter here, fix ToWire keys. + +##### `GET (POST when >=100 ids) api/inventions/v2/batch` — SHAPE_MISMATCH (degraded) + +GET works (parses "id" from query). POST alias binds [FromBody] JSON InventionBatchRequest but the client posts form field "id" → 415 once an id list reaches 100 (large rooms / big owned lists). Items inherit ToWire key gaps. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:142` + +**Fix.** Accept form "id" in the POST action; fix ToWire. + +##### `GET api/inventions/v2/mine` — SHAPE_MISMATCH (degraded) + +Route+verb OK, returns the caller's non-deleted inventions. Only defect is the shared ToWire missing Accessibility/IsCertifiedInvention keys. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:79` + +**Fix.** Fix ToWire (shared). + +##### `POST api/inventions/v6/save` — SHAPE_MISMATCH (degraded) + +Route+verb OK; the camelCase body binds (ASP.NET JSON binding is case-insensitive) including inventionDataFilename, and the response IS correctly wrapped {Status, Invention, InventionVersion}. Remaining gaps: chipsCost/cloudVariablesCost from the request are dropped (no properties, no columns) and the InventionVersion payload lacks ChipsCost/CloudVariablesCost/BlobHash; the Invention payload lacks Accessibility/IsCertifiedInvention (shared ToWire). + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:441` + +**Fix.** Add ChipsCost/CloudVariablesCost to SaveInventionV4Request + version entity and emit them; fix ToWire/ToVersionWire. + +##### `GET api/inventions/v1?inventionId={id}` — SHAPE_MISMATCH (degraded) + +Route+verb+param OK, single object returned; inherits only the shared ToWire missing Accessibility/IsCertifiedInvention keys. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:327` + +**Fix.** Fix ToWire (shared). + +##### `GET api/inventions/v1/room?id={roomId}` — SHAPE_MISMATCH (degraded) + +Route+verb+"id" query OK, array returned; inherits the shared ToWire key gaps. Room-load invention resolution otherwise real. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:320` + +**Fix.** Fix ToWire (shared). + +##### `GET api/inventions/v1/personaldetails/{id}` — SHAPE_MISMATCH (degraded) + +Route+verb OK but the response is {Invention, CanEdit}; the client deserializes CEAFHBOOBKL {"IsCheering":Boolean}. The key is absent → IsCheering reads default false (or throws — UNKNOWN), so the invention detail page never shows the player's own cheer state. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:388` + +**Fix.** Return Ok(new { IsCheering = await db.Cheers.AnyAsync(c => c.FromPlayerId == pid && c.TargetInventionId == id), Invention = ToWire(i), CanEdit = ... }) — extra keys harmless. + +##### `GET showcase/{accountId}` — FIXED + +Was missing everywhere (404), so the profile 'Showcase Rooms' carousel (`RoomListModel.JMGLGEKHHAK.PlayerShowcaseRooms = 7`, fed by `AccountModelController.RoomsShowcaseLinkImpl`) never loaded for any player. + +Now served by `RoomsController.RoomsShowcase` on both `showcase/{accountId:long}` and `roomserver/showcase/{accountId:long}` (the issuing client NLDBPDCNNCF carries the `roomserver/` prefix in this deployment). + +Verb GET is the ordinal 0 moved into rdx before the dispatch call at `NLDBPDCNNCF.txt:4494`, with the literal at ISIL 021 `:4484`; no cmov, so GET only. The response is a **bare JSON array of Int64 room ids** — the issuing method returns `Task>` (`NLDBPDCNNCF.txt:4422`). Room objects are a client-side projection: `IBEOONPEELF.EJPNHLIJNPM` (`IBEOONPEELF.txt:8952`) resolves the ids through the room cache into `FGLDKEJLAKB>`, and the cache entry `b__0` is typed `Task>` (`IBEOONPEELF_NestedType_HHDHJPIJBJB.txt:14`). + +Backing data: `showcase/{0}` is the only showcase literal in the binary — the client reads the list but never writes it — so curation is stored in the existing player-settings table under the key `rooms:showcase` (CSV of room ids), writable via `PUT /settings/v1/{accountId}` with no new schema. Curated ids are re-validated against Rooms on each read (archived/privatised rooms drop out) and returned in the curated order; a player who never curated one falls back to the rooms they created, ordered by HotScore/VisitCount/UpdatedAt. Public rooms only, except the owner viewing their own profile. + +Handler: `DorkNet.Server\Controllers\API\Rooms\V2\RoomsController.cs` (`RoomsShowcase`) + +##### `GET unity_assets/{0}/{1}/{2}` — SHAPE_MISMATCH (degraded) + +A route exists (no host gate, so it answers on every host) but it streams the raw .assetbundle bytes as application/octet-stream. The traced rooms-service method NLDBPDCNNCF.FKLDMPMFLBD deserializes a JSON PPBIFMDLDCB object {"UnityAssetId":String,"Target":Byte,"Version":Int32,"Filename":String,"Hash":String} — feeding it binary bundle bytes fails the JSON parse and the Studio-baked scene resolve. Note the server file's own comment asserts the client downloads bundle BYTES from this URL; the ISIL trace shows at least this metadata call site exists (serializer LKLKHEEHEBC), so whether a second bytes-fetch call site also hits this exact path is UNKNOWN — the fix must keep bytes available for the CDN host while serving JSON to the API-host metadata call. + +Handler: `DorkNet.Server\Controllers\Cdn\CdnController.cs:169` + +**Fix.** Split by host: on the API/rooms host (or a new [HttpGet("unity_assets/{assetId}/{target:int}/{version:int}")] in the API area matched first), return JSON { UnityAssetId = assetId, Target = (byte)target, Version = version, Filename = pick.Filename, Hash = } using the same scene/bundle resolution already in ServeUnityAsset; keep the CDN-host byte streaming for the bare-filename .assetbundle catch-all (CdnController.cs:121) which the client can then fetch by Filename. + +##### `GET api/images/v2/named` — STUB (cosmetic) + +Hardcoded Ok(Array.Empty()). An empty array is a shape-valid response (client caches an empty list), but named/time-windowed promo images can never be served. Per the no-stubs rule this is a defect, though nothing crashes. + +Handler: `DorkNet.Server\Controllers\API\Images\ImagesController.cs:330` + +**Fix.** Back it with a small NamedImageEntity table (FriendlyImageName, ImageName, StartTime, EndTime) + admin CRUD, and return the rows with those exact PascalCase keys. + +##### `GET api/inventions/v1/tagfilters` — SHAPE_MISMATCH (cosmetic) + +Returns {PinnedFilters, PopularFilters} but the 2023 DTO AKCLLEJNFFD also reads TrendingFilters:[String]; the missing list yields null/empty for callers projecting that list. The tag list is also hardcoded (functional stub). + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:205` + +**Fix.** Add TrendingFilters = tags to the anonymous object; ideally derive lists from actual TagsCsv frequency instead of the hardcoded array. + +##### `POST api/inventions/v1/report` — SHAPE_MISMATCH (cosmetic) + +Request binds cleanly (InventionId/ReportCategory/Details match, case-insensitive). Response is {Reported:true} but the client deserializes PHMHCPEMABG {"Success":Boolean,"Message":String} — Success reads default false, so the report UI can show failure even though the report row persisted. + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:720` + +**Fix.** Return Ok(new { Success = true, Message = string.Empty }). + +##### `POST remote-run/push-to-studio` — IMPLEMENTED + +Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs` → `PushToStudio`, routed at both `remote-run/push-to-studio` and `roomserver/remote-run/push-to-studio`. + +Verb POST from EHIOLHBGODG.txt:411-417 (route literal into r9, `Move rdx, 2` = HTTPMethods.Post, no cmov). Body is a RAW JSON document — :433 passes the serialized DTO to `BNDIAONDFFF.FJLLPHFOOJJ` (RawJsonForm, `application/json`) — bound through `FormOrJsonModelBinder`. Request FNAGBPCAGJD keys are literal in the generated serializer OFMNNCMPEPA.txt:535-698: `SessionId, RoomId, SubRoomId, UnityAssetId, RoomData, SubRoomData, SavedByAccountId`; each blob ref is `{Filename, Hash, OwnershipProof}` (FLELPHJDLNG.txt:255/274/290). Response is a single CEELGOLBHIL — eight keys, literal in its reader PPHLKNGCGOE.txt:599-786: `SessionId, RoomId, SubRoomId, UnityAssetId, RoomDataFilename, RoomDataHash, SubRoomDataFilename, SubRoomDataHash`; RoomId/SubRoomId are `Nullable` (CEELGOLBHIL.txt:25/51) and the two blob refs are FLATTENED (OwnershipProof is not echoed). A non-2xx or malformed reply shows the client's "Failed to push to Rec Room Studio" toast (EHIOLHBGODG.txt:445). + +This is the Studio remote-run handoff, a sibling of the normal save commit, not a replacement for it (client-side: OGPDOMCNIFM.txt:271 `rooms/{id}/subrooms/{id}/data` vs :449 push) — so the server does NOT touch `RoomEntity.CurrentDataBlobName`. It authorizes the caller like a room save (creator / admin / accepted CoOwner), requires both pushed filenames to resolve to real `RoomDataBlobEntity` rows (the client uploads them immediately beforehand), stamps the sub-room save blob with its `RoomId`/`SubRoomId` so it shows up in that sub-room's `datahistory`, records the pushed `UnityAssetId` on the `RoomSceneEntity`, writes `SessionId` into `RoomEntity.StudioSessionId`, and keeps the full push under `remoterun:{SessionId}` in `PlayerSettingEntity`. `IsRoomLinkedToRecRoomStudio` is intentionally left alone (it changes in-room MakerPen UI and the client has no unlink call). + +**Still out of scope:** live relay of the details to a second logged-in session. The client's receive side (`EHIOLHBGODG.MCPBJLHFOBC(CEELGOLBHIL)` → the `Action` subscribed at MBOJJFBIAGE.txt:366/978) has no call site anywhere in the IsilDump, so the notification id carrying it cannot be established from the binary; `PushNotificationId` gets no invented member for it. + +### Storefront, purchases and subscriptions + +`commerce-subscription` + +Verified all 39 real HTTP routes against the DorkNet march-2023 branch. 4 hard breaks: POST/DELETE subscription/{accountId} (creator-club subscribe/unsubscribe) is entirely missing; PurchaseRoomKeyWithCurrency is GET-only while the client POSTs (405) and its response shape is wrong; buyInvention returns a balance wrapper without the InventionResponse envelope the client requires; trialInvention binds inventionId from query while the client sends it as a form field (always 404) and returns a bare invention instead of the {Status,Invention,InventionVersion} wrapper. 8 shape mismatches that degrade UI: season/{id} (nearly every key differs from GEEMFIMOPBH), v2/balance and buyProgressionEventXpBoost (Data must be a single object, server sends an array), buyPurchaseReminder ({Success} instead of JNPGKDJJPPM), initiatepurchase (TransactionId string vs Int64, and skuId form field never read), CampusCard UpdateAndGetSubscription (inner subscription missing Level/Period/ExpirationDate/etc.), subscription/details and top/creators/today (missing ClubId), trialInvention/duration (object vs bare int), apple musicpromotion active/code (object vs bare bool; missing Result/Url keys — iOS-only). Cross-cutting: every BalanceUpdateResponse container emits "BalanceType" where the 2023 client's wire key for that property is "Platform". Several buy endpoints are behavioral stubs (buyTier/buyElite/buyForFreeGiftButton ignore the request and grant nothing/wrong things). + +**Client-side notes.** HTTP plumbing (applies to every endpoint): requests are built as BNDIAONDFFF objects via .ctor(BestHTTP.HTTPMethods verb, GJDLNNLKDIJ host, String route) — BNDIAONDFFF.txt:74. Verb enum is BestHTTP.HTTPMethods: 0=GET, 2=POST, 4=DELETE (observed values). Host enum values seen: 2 (purchase/campaign), 26 (api/storefronts, CampusCard), 13 (subscription/club), 1 (AppIntegrity/apple/playstationplus) — all should resolve to the same DorkNet host. BNDIAONDFFF.AFGEDDANEKP(name, value) appends KeyValuePair params (BNDIAONDFFF.txt:450-646): sent as query string on GET/DELETE and form-urlencoded body on POST; BNDIAONDFFF.FJLLPHFOOJJ(string) sets a raw JSON body. Response DTOs are read by generated formatters whose key tables accept PascalCase, camelCase, AND all-lowercase variants and whose serializers write PascalCase (e.g. GBPHFPMCGHH.txt:203-238) — so PascalCase responses are always safe. Enums are numeric on the wire (JECENNBIMEI converters, OLIHBGPDPHF.txt:780-822). Quirk: BalanceResponseDTO's third property (JLBBOFOPOGL BalanceType) uses wire key "Platform" (PECGEJAAMHB.txt) in all BalanceUpdateResponse containers, while JNPGKDJJPPM (buyPurchaseReminder) uses "BalanceType" — do not unify them. SERVER GAPS FOUND (march-2023 branch): (1) no POST/DELETE subscription/{accountId} — creator-club subscribe/unsubscribe 404s (client IKMMOCKDKAF.txt:6560/6855; server only has GET subscription/details|subscriberCount|mine|top per server-routes.json); (2) api/storefronts/v1/PurchaseRoomKeyWithCurrency is GET-only [FromQuery] on the server (StorefrontsBuyController.cs:217) but the 2023 client POSTs form fields RoomKeyId/RequestedPrice/RequestedPurchaseCurrencyId (DCFKEFHJAGC.txt:7452-7497); (3) purchase/v1/initiatepurchase server returns TransactionId as string "txn-…" (PurchaseController.cs:58) but the client deserializes {TransactionId: Int64} (HPGPBGLFHMA.txt + EAIGBBHMIKM_NestedType_GLBNBBCPFKF.txt Int64 getter) — must be a JSON number; (4) api/AppIntegrity/v1/iosproducts is GET on the server but POST in the client (iOS-only, harmless for PC preservation). Cache-key literal reuse: the route strings purchasecampaign/allcurrent/v2, purchase/v1/hasspentmoney, reminder/currentTokenBundles/v2, api/catalog/v1/all?onlyAvailableSkus=true also appear as cache keys in EAIGBBHMIKM.txt:41,2733-2777,3479 — those occurrences are not extra HTTP calls. Related route living in the same client class but grouped elsewhere: GET api/catalog/v1/all?onlyAvailableSkus=true -> List (SkuId:Int32, Name, Description, ImageName, Price:Int32, OculusSkuId, AppleProductId, PsnProductLabel, XboxProductId, XboxStoreId, GooglePlaySkuId, … keys in BGFBJCIOIFP.txt:999-1234) feeding the IAP flow; also api/roomCurrencies/v2/purchase and api/roomconsumables/v1/roomconsumable/{guid}/purchase/{tokens|currency} are issued from DCFKEFHJAGC.txt:7794/8252/8726 but belong to the room-currency/consumables subsystems. + +#### Endpoints + +| Verb | Route | Request | Response | +|---|---|---|---| +| POST | `api/AppIntegrity/v1/iospaymentqueuefailed` | raw JSON body: the failure-description string (closure GAAPALMODOL/MEAMPIIMJDC serializes the single String arg, sent via FJLLPHFOOJJ) | {"Success": Boolean, "Message": String} (PHMHCPEMABG) | +| POST | `api/AppIntegrity/v1/iosproducts` | raw JSON body: array of {"Name": String, "Price": Single, "ProductId": String} (serialized product list, sent via BNDIAONDFFF.FJLLPHFOOJJ raw-JSON body helper) | {"Success": Boolean, "Message": String} (PHMHCPEMABG; deserializer accepts PascalCase/camelCase/lowercase key variants, serializer writes PascalCase — same for every DTO below) | +| GET | `api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers` | none | bare JSON boolean (true/false) | +| POST | `api/CampusCard/v1/UpdateAndGetSubscription` | form/query pair 'accessToken': String (platform IAP token; AFGEDDANEKP adds KeyValuePair, form-encoded on POST) | {"Subscription": {"SubscriptionId": Int64, "RecNetPlayerId": Int32, "PlatformType": Int32? (enum HHJIBNMLOAC), "PlatformId": String, "PlatformPurchaseId": String, "Level": Int32 (enum LOMOPBPNFCE), "Period": Int32 (enum | +| GET | `api/apple/musicpromotion/active` | none | bare JSON boolean | +| GET | `api/apple/musicpromotion/code` | none | {"Result": Int32 (enum AppleMusicPromotionResponseDTO/CABJNOJGGJF), "Code": String, "Url": String, "RedemptionUrl": String} | +| POST | `api/playstationplus/expire` | none (no form fields; fire-and-forget via BNDIAONDFFF.KDOPJCNKOOK) | ignored — any 2xx works; body not parsed (only Func error mapper) | +| POST | `api/storefronts/v1/PurchaseRoomKeyWithCurrency` | form fields: 'RoomKeyId': Int64, 'RequestedPrice': Int64, 'RequestedPurchaseCurrencyId': Guid | {"Balance": {"AccountId": ?, "CurrencyId": Guid, "Balance": Int64, "ModifiedAt": DateTime} (LGAMPMJNGFH room-currency balance), "RoomKeyResponse": {"Status": Int32, "RoomKey": {...}} (ONLIDBLNMCC)} | +| GET | `api/storefronts/v1/adcarouselitems` | none | [{"AdCarouselItemId": Int32, "ImageName": String, "Title": String, "Description": String, "PurchasableItemIds": [Int32], "PurchaseReminderId": Int32?}] | +| GET | `api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int}` | none (path params Int32, Int32) | {"CurrencyType": Int32 (enum EAFDEJBEFJB), "BalanceAddType": Int32 (enum HPCEGKLGPHC), "BaseAward": Int32, "BonusAwardMin": Int32, "BonusAwardMax": Int32, "RateLimitType": Int32, "IgnorePartialMultiplier": Boolean, "MaxP | +| POST | `api/storefronts/v1/buyForFreeGiftButton` | raw JSON body (RequestPurchaseForFreeGiftButtonDTO): {"PurchasableItemId": Int32, "CouponConsumablePlayerMappingId": Int64?, "Count": Int32, "CreatorPlayerId": Int32, "RequestedPri | {"Balance": Int64, "CurrencyType": Int32 (enum), "Platform": Int32 (enum; wire key for BalanceType)} (BalanceResponseDTO) | +| POST | `api/storefronts/v1/buyProgressionEventXpBoost` | form fields: 'progressionEventId': Int64, 'purchasableXpBoostId': Guid, 'requestedPrice': Int32, 'expectedXp': Int32 | {"BalanceUpdates": [{"UpdateResponse": Int32, "Data": {"Xp": Int32}}], "Balance": Int64, "CurrencyType": Int32, "Platform": Int32} | +| POST | `api/storefronts/v1/buyPurchaseReminder` | form fields: 'purchaseReminderId': Int32, 'requestedPrice': Int64 | {"Balance": Int64, "CurrencyType": Int32 (enum EAFDEJBEFJB), "BalanceType": Int32 (enum JLBBOFOPOGL — note key is BalanceType here, not Platform), "Data": [FHMABOHAEED...]} (JNPGKDJJPPM) | +| GET | `api/storefronts/v1/buyRoomKey` | query params: 'RoomKeyId': Int64, 'RequestedPrice': Int64 (note PascalCase keys here, unlike buyInvention's camelCase — ASP.NET binding is case-insensitive but exact casing is as c | {"RoomKeyResponse": {"Status": Int32 (enum GDIOEPIOPEE), "RoomKey": {room-key DTO}} (ONLIDBLNMCC), "BalanceUpdateResponse": {"BalanceUpdates": [{"UpdateResponse": Int32, "Data": {PurchaseBalanceModificationDTO}}], "Balan | +| POST | `api/storefronts/v1/objectives` | raw JSON body: array of per-objective Dictionary: [{"objectiveType": Int32, "completionPercentage": Single, "roomId": Int64 (omitted when null)}] | ignored — any 2xx works; body not parsed | +| GET | `api/storefronts/v1/season/{storefrontType:int}` | none (path param Int32) | {"Season": Int32, "Name": String, "StartAt": DateTime, "EndAt": DateTime, "CurrencyType": Int32 (enum EAFDEJBEFJB), "EliteUpgrade": {"PurchasableItemId": Int32, "Type": Int32, "Prices": ?, "SubscriberPrices": ?, "IsFeatu | +| GET | `api/storefronts/v1/toptoday` | none | bare JSON array of Int32 purchasableItemIds, e.g. [101,102] | +| POST | `api/storefronts/v1/trialInvention` | form field: 'inventionId': Int64 | {"Status": Int32 (enum CFHBHALNFKC), "Invention": {IFJONDCAKKM — see buyInvention for the full 22-key shape}, "InventionVersion": {PLIKEBBPJGI — see buyInvention}} (BDNCJIPHHOK; client then maps to the Invention via Func | +| GET | `api/storefronts/v1/trialInvention/duration` | none | bare JSON integer (Int32 trial duration) | +| POST | `api/storefronts/v2/balance` | raw JSON body: UnityEngine.JsonUtility.ToJson(GrantBalanceRewardDTO) — field names are Unity-serialized public fields NOT visible in ISIL; the server's previously-validated shape i | {"BalanceUpdates": [{"UpdateResponse": Int32 (enum DCFKEFHJAGC/CABBDKFODEC purchase-result code), "Data": {"BalanceAddType": Int32, "BaseAward": Int32, "BonusAward": Int32, "RateLimit": Int32, "CurrentCount": Int32, "Tot | +| POST | `api/storefronts/v2/buyElite` | raw JSON body RequestPurchaseItemDTO (same as buyItem) | same as buyItem (PurchaseBalanceUpdateResponseDTO`1) | +| GET | `api/storefronts/v2/buyInvention` | query params: 'inventionId': Int64, 'requestedPrice': Int32 (AFGEDDANEKP pairs on a GET request go to the query string) | {"InventionResponse": {"Status": Int32 (enum CFHBHALNFKC), "Invention": {"InventionId": Int64, "ReplicationId": Guid, "CreatorPlayerId": Int32, "Name": String, "Description": String, "ImageName": String, "CurrentVersionN | +| POST | `api/storefronts/v2/buyItem` | raw JSON body (RequestPurchaseItemDTO): {"StorefrontType": Int32 (enum BJGDDFLENAO), "PurchasableItemId": Int32, "CurrencyType": Int32 (enum EAFDEJBEFJB), "RequestedPrice": Int64?, | {"BalanceUpdates": [{"UpdateResponse": Int32 (enum CABBDKFODEC: maps to client toasts 'Not enough X', 'You already own this', 'The price ... has changed', 'Too many requests', 'Coupon could not be applied'), "Data": [FHM | +| POST | `api/storefronts/v2/buyTier` | raw JSON body RequestPurchaseItemDTO (same shape as buyItem, Gift null) | same as buyItem: {"BalanceUpdates": [{"UpdateResponse": Int32, "Data": [FHMABOHAEED...]}], "Balance": Int64, "CurrencyType": Int32, "Platform": Int32} | +| GET | `api/storefronts/v3/giftdropstore/{storefrontType:int}` | none (path param Int32 = storefront type) | {"SubscriberDiscountPercent": ?, "StorefrontType": Int32, "NextUpdate": DateTime, "NewUntil": DateTime, "StoreItems": [...]} (HOEGLKNEIOF; StoreItems entries carry the purchasable-item keys PurchasableItemId/Type/Prices/ | +| GET | `api/storefronts/v4/balance/{currencyType:int}` | none (path param Int32 = (int)EAFDEJBEFJB) | JSON array: [{"Balance": Int64, "CurrencyType": Int32 (enum EAFDEJBEFJB), "Platform": Int32 (enum JLBBOFOPOGL; NOTE the wire key for the BalanceType property is 'Platform')}] — client reduces List to | +| POST | `purchase/v1/cancelpurchase` | form fields: 'transactionId': Int64, 'accessToken': String | ignored — any 2xx; body not parsed | +| POST | `purchase/v1/cleanuppending` | form field: 'accessToken': String | ignored — any 2xx; body not parsed | +| POST | `purchase/v1/completepurchase` | form fields: 'transactionId': Int64, 'accessToken': String | ignored — any 2xx; body not parsed | +| GET | `purchase/v1/hasspentmoney` | none | bare JSON boolean | +| POST | `purchase/v1/initiatepurchase` | form fields: 'skuId': Int32, 'accessToken': String, 'purchaseReminderId': Int32? | {"TransactionId": Int64} (GLBNBBCPFKF; client maps to Int64 via Func) | +| POST | `purchase/v1/processpurchase` | form fields: 'purchaseDetails': String, 'accessToken': String, 'purchaseReminderId': Int32? | ignored — any 2xx; body not parsed | +| GET | `purchasecampaign/allcurrent/v2` | none | [{"PurchaseCampaignId": Int32, "PurchaseReminder": {GCNMNDLBPLO — same shape as reminder/currentTokenBundles/v2 entry} \| null, "Name": String, "TriggerFlags": Int32 (enum PDKALPDKNJC), "LastShown": DateTime, "ShowCount" | +| POST | `purchasecampaign/shown` | form field: 'purchaseCampaignId': Int32 | ignored — any 2xx; body not parsed | +| GET | `reminder/currentTokenBundles/v2` | none | [{"PurchaseReminderId": Int32, "SkuId": Int32?, "TokenPrice": Int32?, "Title": String, "Description": String, "ImageName": String, "EndDate": DateTime?, "BonusGiftDrops": [EFFIEFEFHHB: {"GiftDropId": ?, "FriendlyName": S | +| GET | `subscription/details/{accountId:int}` | none (path param Int32) | {"AccountId": Int32, "ClubId": Int64, "SubscriberCount": Int32} (single HHOCDLAFOKB object, not a list) | +| GET | `subscription/mine/member` | query params (sorted variant only): 'sort': Int32 (enum MJLECOMCJCN), 'skip': Int32, 'take': Int32; cache-refresh variant sends none | [{"AccountId": Int32, "ClubId": Int64, "SubscriberCount": Int32}] (List) | +| GET | `subscription/subscriberCount/{accountId:int}` | none (path param Int32) | bare JSON integer (Int32) | +| GET | `subscription/top/creators/today` | none | [{"AccountId": Int32, "ClubId": Int64, "SubscriberCount": Int32}] | +| POST | `subscription/v1/cancel` | form field: 'accessToken': String | ignored — any 2xx; body not parsed | +| POST | `subscription/{accountId:int}` | POST: form field 'roomId': Int64 (current room id via ObscuredLong, omitted when -1). DELETE: none. | ignored — any 2xx; body not parsed (client then refreshes subscription cache and fires 'CreatorClubSubscriptionUpdate' handling) | + +#### Not HTTP routes + +These literals look like paths but are cache keys, deeplinks or MIME types: + +- `api/storefronts/` — Not a route: this literal is a URL PREFIX combined via String.Format into 4 real routes (listed separately): '{0}v1/season/{1}', '{0}v4/balance/{1}', '{0}v1/balanceAddType/{1}/{2}', '{0}v3/giftdropstore/{1}'. +- `store/invention/{0}` — Same as store/item/{0}: rec.net share deeplink for an invention (formatted with Int64 InventionId), passed to HIHEDOIIHGG/LinkManager; not an API request. +- `store/item/{0}` — NOT an HTTP API route: rec.net web deeplink path formatted and handed to the sharing/link service HIHEDOIIHGG (implemented by RecRoom.Sharing.LinkManager) for share links/QR codes — no BNDIAONDFFF request is built. + +#### Defects + +##### `GET api/storefronts/v2/buyInvention` — SHAPE_MISMATCH (breaks-gameplay) + +GET is registered and the query param inventionId binds, but the response is the generic balance wrapper {Balance,CurrencyType,BalanceType,BalanceUpdates:[{UpdateResponse,Data:[]}]}. The client deserializes InventionPurchaseResponseDTO: {InventionResponse:{Status (enum CFHBHALNFKC), Invention (IFJONDCAKKM, 22 keys), InventionVersion (PLIKEBBPJGI incl. BlobName/BlobHash)}, BalanceUpdateResponse:{...}}. InventionResponse is entirely absent, so Status/Invention/InventionVersion are null/default — the client cannot confirm the purchase or spawn the invention; MakerPen store buying is broken. Also 'requestedPrice' is ignored, and the ownership record goes to an ObjectiveProgress key rather than anything the inventions API reads back — UNKNOWN whether ownership is visible elsewhere. + +Handler: `DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs:255` + +**Fix.** Wrap the response: { InventionResponse = { Status = , Invention = InventionsController.ToWire(inv), InventionVersion = }, BalanceUpdateResponse = { BalanceUpdates:[{UpdateResponse:0, Data:{BalanceAddType,Delta,Balance,BalanceType,CurrencyType} (single object, PurchaseBalanceModificationDTO)}], Balance, CurrencyType, Platform } } — reuse the invention wire builders from InventionsController, and record ownership where the inventions 'mine/saved' queries can see it. + +##### `POST api/storefronts/v1/PurchaseRoomKeyWithCurrency` — VERB_MISMATCH (breaks-gameplay) + +Server registers the route only as a [HttpGet] alias of BuyRoomKey with [FromQuery] roomKeyId/requestedPrice. The 2023 client POSTs form fields RoomKeyId/RequestedPrice/RequestedPurchaseCurrencyId → 405 Method Not Allowed; room keys priced in a room-specific currency cannot be bought at all. Even with POST accepted, the handler ignores RequestedPurchaseCurrencyId (charges tokens) and returns {RoomKeyResponse,BalanceUpdateResponse}, whereas the client's RoomKeyPurchaseWithCurrencyResponseDTO expects {Balance:{AccountId,CurrencyId (Guid),Balance (Int64),ModifiedAt (DateTime)} (room-currency balance LGAMPMJNGFH), RoomKeyResponse:{Status,RoomKey}}. + +Handler: `DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs:217` + +**Fix.** Add a dedicated [HttpPost("api/storefronts/v1/PurchaseRoomKeyWithCurrency")] handler reading form fields RoomKeyId (long), RequestedPrice (long), RequestedPurchaseCurrencyId (Guid); debit the player's room-currency balance for that currency GUID (room-currencies tables), grant the key, and return { Balance = {AccountId, CurrencyId, Balance, ModifiedAt}, RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key) }. + +##### `POST api/storefronts/v1/trialInvention` — SHAPE_MISMATCH (breaks-gameplay) + +POST exists but (1) binds inventionId with [FromQuery] while the 2023 client sends it as a form-urlencoded field (AFGEDDANEKP pairs go to the body on POST) → inventionId=0 → 404 NotFound on every trial attempt; and (2) even if it bound, the response is the bare invention wire object while the client deserializes BDNCJIPHHOK {Status (enum CFHBHALNFKC), Invention (IFJONDCAKKM), InventionVersion (PLIKEBBPJGI)} and then projects .Invention — a bare invention leaves Invention null. Invention free trials never work. + +Handler: `DorkNet.Server/Controllers/API/Inventions/InventionsController.cs:345` + +**Fix.** Accept the form field (e.g. [FromForm] long inventionId with [FromQuery] fallback) and return { Status = , Invention = ToWire(i), InventionVersion = }. + +##### `POST subscription/{accountId:int}` — FIXED (was MISSING, breaks-gameplay) + +Implemented in `DorkNet.Server/Controllers/API/Subscriptions/SubscriptionsController.cs`: `[HttpPost("/subscription/{accountId:long}")]` binds the optional `roomId` form field through `FormOrJsonModelBinder` and `[HttpDelete("/subscription/{accountId:long}")]` takes no body. Both write/remove the canonical player→player `Subscriptions` row AND mirror into `ClubSubscriptions` for the target's creator club (oldest club they own, the same resolution `subscription/details` reports as `ClubId`) so the client's post-write refresh of `subscription/mine/member` actually shows the subscription. Both are idempotent and return a bare 200 (the client parses no body). `roomId` is attribution only — no column exists for it, so it is logged rather than persisted. + +Original finding: No handler accepts POST or DELETE on subscription/{accountId} anywhere in the server (verified by grepping all HttpPost/HttpDelete attributes under subscription/: only /subscription/v1/* POSTs exist, whose literal 'v1' segment cannot match a numeric account id; the follow-graph mutations live at api/playersubscriptions/v1/subscribe/{targetId} which the 2023 client never calls). The client's subscribe (POST, form field roomId:Int64, omitted when -1) and unsubscribe (DELETE, no body) both 404 → 'Failed to subscribe to {0}' / 'Failed to unsubscribe from {0}' toasts; creator-club subscribing is entirely broken from the 2023 client. + +**Fix.** Add to PlayerSubscriptionsController (or ClubsController): [HttpPost("/subscription/{accountId:long}")] reading optional form field roomId and creating the subscription row (same semantics as Subscribe), and [HttpDelete("/subscription/{accountId:long}")] removing it. Return 200 with empty/any body (client ignores it, then refreshes via subscription/mine/member). Keep the row source consistent with what subscription/mine/member reads (ClubSubscriptions vs Subscriptions — currently mine/member reads ClubSubscriptions while the playersubscriptions endpoints write Subscriptions; the new handlers must write the table mine/member reads, or the subscribe will never show up). + +##### `POST api/CampusCard/v1/UpdateAndGetSubscription` — SHAPE_MISMATCH (degraded) + +POST exists, outer keys Subscription/PlatformAccountSubscribedPlayerId match, but the inner Subscription object (client IENPGNCIHOK) is missing PlatformType, PlatformId, PlatformPurchaseId, Level (Int32 enum LOMOPBPNFCE), Period (Int32 enum BHEIDLHDPKF), ExpirationDate (DateTime), IsAutoRenewing (Boolean), CreatedAt, ModifiedAt. Server instead emits isActive/startedAt/currentPeriodEnd/renewalDate, none of which exist in the client DTO. Missing Level defaults to 0 and ExpirationDate to default(DateTime), so the RR+ status refresh reads the subscription as no-tier/expired — RR+ never shows active — and the missing non-nullable DateTime fields risk a strict-reader throw. Also note the client sends only a form field 'accessToken'; the server's 'subscription'/'platformAccountSubscribedPlayerId' fields are never sent, so the handler always takes the self-target path (harmless). + +Handler: `DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs:44` + +**Fix.** In CompatibilityFeatureController.UpdateAndGetCampusCardSubscription, replace the inner dictionary with the full IENPGNCIHOK shape: SubscriptionId (Int64), RecNetPlayerId (Int32), PlatformType (Int32? or null), PlatformId (String), PlatformPurchaseId (String), Level (Int32, non-zero = subscribed tier), Period (Int32), ExpirationDate (DateTime far-future), IsAutoRenewing (Boolean), CreatedAt, ModifiedAt. Keep PlatformAccountSubscribedPlayerId on the outer object. + +##### `GET api/storefronts/v1/season/{storefrontType:int}` — SHAPE_MISMATCH (degraded) + +Route exists but virtually every key differs from the client's GEEMFIMOPBH: server sends SeasonId/SeasonType/Active/StartsAt/EndsAt and Tiers entries {TierId,DisplayName,Description,Price,CurrencyType,ImageName}; client reads Season (Int32), Name, StartAt, EndAt (note no 's'), CurrencyType, EliteUpgrade (DIPHILPNLAN purchasable-item object), Tiers as [{Tier:Int32, Rewards:[purchasable items with PurchasableItemId/Type/Prices/SubscriberPrices/IsFeatured/AvailableAt/AvailableUntil/NewUntil]}], and PersonalDetails (BOMJCPNECFG {HasEliteUpgrade,CurrentSeasonTier,ModifiedAt}). Every client field lands on its default — the season/battle-pass page renders empty (no tiers, no elite upgrade, tier 0), and the non-nullable DateTime fields (StartAt/EndAt) risk strict-reader throws. + +Handler: `DorkNet.Server/Controllers/API/Store/StorefrontsController.cs:234` + +**Fix.** Rewrite the v1/season handler to emit the GEEMFIMOPBH shape: {Season, Name, StartAt, EndAt, CurrencyType, EliteUpgrade:{PurchasableItemId,Type,Prices,SubscriberPrices,IsFeatured,AvailableAt,AvailableUntil,NewUntil}, Tiers:[{Tier, Rewards:[same purchasable shape, reuse StoreService.BuildPurchasableGiftDrop-style rows]}], PersonalDetails:{HasEliteUpgrade,CurrentSeasonTier,ModifiedAt}}. Note the path param is the storefront-type enum, not a season id. + +##### `POST api/storefronts/v2/balance` — SHAPE_MISMATCH (degraded) + +Handler exists and reads the request correctly (case-insensitive JSON {CurrencyType,BalanceAdds:[{Multiplier,BalanceAddType}]}), but the response nests ALL modifications as an ARRAY under one BalanceUpdates entry's Data. The client's BalanceUpdateResponseDTO`1 expects Data to be a SINGLE object per BalanceUpdates entry — an array where an object is expected makes the generated reader throw, so client-initiated reward grants (activity/event rewards) fail to parse even though the currency was actually granted server-side. Additionally the outer object emits "BalanceType" where the client's wire key for that property is "Platform" (PECGEJAAMHB quirk). + +Handler: `DorkNet.Server/Controllers/API/Store/StorefrontsController.cs:341` + +**Fix.** In StorefrontsController.BalanceUpdateResponse (or a v2/balance-specific variant): emit one BalanceUpdates entry per modification with Data as the bare modification object (RewardModification already has the right keys BalanceAddType/BaseAward/BonusAward/RateLimit/CurrentCount/Total/BalanceType/BalanceInGiftBox), and add "Platform": 0 to the outer object. + +##### `POST api/storefronts/v2/buyTier` — STUB (degraded) + +POST exists and the response parses (PurchaseBalanceUpdateResponseDTO family, empty Data list), but the handler deducts a fixed 1-token cost regardless of the tier's real price and records nothing — no season-tier progress is granted, so the season page's CurrentSeasonTier never advances after a 'buy tier skip'. Outer "Platform" key also missing (BalanceType emitted instead). + +Handler: `DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs:188` + +**Fix.** Charge req.PurchasableItemId's actual price and persist tier progress (e.g. bump the value backing PersonalDetails.CurrentSeasonTier used by the v1/season response) so the season UI reflects the purchase; add Platform key. + +##### `POST api/storefronts/v2/buyElite` — STUB (degraded) + +POST exists, response parses, but the handler charges nothing and records nothing — HasEliteUpgrade never flips in the season PersonalDetails, so the elite upgrade purchase appears to succeed then reverts. Outer "Platform" key missing. + +Handler: `DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs:203` + +**Fix.** Deduct the elite price and persist a per-player elite flag that the (to-be-fixed) v1/season handler surfaces as PersonalDetails.HasEliteUpgrade = true. + +##### `POST api/storefronts/v1/buyForFreeGiftButton` — STUB (degraded) + +POST exists and the response parses well enough (Balance/CurrencyType present; "Platform" key missing, extra keys skipped), but the handler binds [FromQuery] currencyType/price while the client sends a raw JSON body {PurchasableItemId,CouponConsumablePlayerMappingId,Count,CreatorPlayerId,RequestedPrice}. The body is never read: price=0 (nothing charged) and, critically, the requested PurchasableItemId is never granted — instead a hardcoded 25-token 'FreeGiftButton' gift package is inserted. In-room free-gift-button gadgets hand out the wrong reward every time. + +Handler: `DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs:283` + +**Fix.** Read the JSON body into a RequestPurchaseForFreeGiftButtonDTO (PurchasableItemId, CouponConsumablePlayerMappingId?, Count, CreatorPlayerId, RequestedPrice?), resolve the store item by PurchasableItemId and grant THAT item (Count times), charge RequestedPrice when set, and return { Balance, CurrencyType, Platform } (BalanceResponseDTO trio with the Platform wire key). + +##### `POST api/storefronts/v1/buyPurchaseReminder` — SHAPE_MISMATCH (degraded) + +POST exists but is a stub: it ignores the client's form fields purchaseReminderId/requestedPrice, writes an ObjectiveProgress marker, and returns {Success:true}. Client deserializes JNPGKDJJPPM {Balance:Int64, CurrencyType:Int32, BalanceType:Int32 (key IS 'BalanceType' here, not 'Platform'), Data:[FHMABOHAEED gift packages]} — every field missing → defaults (balance shows 0) and nothing is charged or granted, so buying the reminder-advertised item silently no-ops. Low real-world impact since reminder/currentTokenBundles/v2 returns an empty list, so no reminder popups exist to buy from. + +Handler: `DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs:329` + +**Fix.** Read form fields purchaseReminderId (int) / requestedPrice (long); charge and grant the reminder's item, then return { Balance, CurrencyType, BalanceType, Data = [gift package objects in the FHMABOHAEED shape used by buyItem] }. + +##### `POST api/storefronts/v1/buyProgressionEventXpBoost` — SHAPE_MISMATCH (degraded) + +POST exists but binds [FromQuery] currencyType/price; the client sends form fields progressionEventId/purchasableXpBoostId/requestedPrice/expectedXp which never bind → nothing charged, no XP granted. Response reuses BalanceUpdateResponse whose BalanceUpdates[0].Data is an empty ARRAY, while the client's BalanceUpdateResponseDTO`1 expects Data as a single OBJECT {"Xp":Int32} — array-vs-object makes the reader throw. Outer "Platform" key also missing. + +Handler: `DorkNet.Server/Controllers/API/Store/StorefrontsBuyController.cs:308` + +**Fix.** Read the four form fields, charge requestedPrice, grant expectedXp via LevelService.AwardXpAsync, and return { BalanceUpdates:[{UpdateResponse:0, Data:{ Xp = expectedXp }}], Balance, CurrencyType, Platform = 0 }. + +##### `GET api/storefronts/v1/trialInvention/duration` — SHAPE_MISMATCH (degraded) + +Server returns {Duration:300} (a shape chosen for the 2020.12 client per its own doc comment), but the 2023 client on this branch deserializes a BARE JSON integer (Task, no projection) — an object where an int is expected fails the read, so the trial-length fetch errors. + +Handler: `DorkNet.Server/Controllers/API/Store/StorefrontsController.cs:95` + +**Fix.** On the march-2023 branch return Content(duration.ToString(), "application/json") — a bare integer body. + +##### `POST purchase/v1/initiatepurchase` — SHAPE_MISMATCH (degraded) + +POST route exists but is contract-incompatible twice over: (1) the client sends form fields skuId/accessToken/purchaseReminderId; the server binds [FromForm] PurchaseRequest(ItemId,Slug,Quantity) so nothing matches, ResolveItemAsync returns null, and the reply is {success:false,error:"item_not_found"} with NO TransactionId key; (2) even the success branch returns TransactionId as the STRING "txn-…" while the client's GLBNBBCPFKF.TransactionId is Int64 — the strict reader needs a JSON number. Every real-money token-bundle/RR+ checkout initiation therefore rejects its promise (error toast). Steps 2-5 of the flow are ack-only so this is the only blocking one. + +Handler: `DorkNet.Server/Controllers/API/Store/PurchaseController.cs:43` + +**Fix.** In PurchaseController.Initiate read form fields skuId (int), accessToken (string), purchaseReminderId (int?) and return { TransactionId = } (e.g. a DB row id or ticks) as a JSON number. + +##### `GET subscription/details/{accountId:int}` — SHAPE_MISMATCH (degraded) + +GET exists but returns {accountId,subscriberCount,subscribedCount,isSubscribed}. The client's HHOCDLAFOKB reads AccountId, ClubId (Int64), SubscriberCount — ClubId is absent so it defaults to 0, and the calling method is literally GetCreatorClubIdForSubscription: resolving a creator's club id from their profile/subscribe UI always yields 0, breaking the profile→club navigation for subscriptions. + +Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:207` + +**Fix.** Add ClubId to the response: look up the creator's club (db.Clubs.Where(c => c.CreatorPlayerId == accountId)) and emit { AccountId, ClubId, SubscriberCount } (keep the extra keys if the 2020 watch needs them — extra keys are skipped). + +##### `GET api/apple/musicpromotion/active` — SHAPE_MISMATCH (cosmetic) + +Client deserializes the body as a BARE JSON boolean; server returns an object {Active,StartAt,EndAt}. If fired, the Boolean reader fails on '{'. Call site is the iOS Apple Music promo, so the PC build likely never issues it. + +Handler: `DorkNet.Server/Controllers/API/Apple/AppleMusicPromotionController.cs:13` + +**Fix.** Return Content("false","application/json") (or "true" if the promo should show) instead of the object. + +##### `GET api/apple/musicpromotion/code` — SHAPE_MISMATCH (cosmetic) + +GET is registered (plus an unused POST). Server returns {Code,Redeemed}; client AppleMusicPromotionResponseDTO reads Result (Int32 enum, non-nullable — defaults to 0 or throws), Code, Url, RedemptionUrl. Missing Result/Url/RedemptionUrl. iOS-only on this build. + +Handler: `DorkNet.Server/Controllers/API/Apple/AppleMusicPromotionController.cs:22` + +**Fix.** Return { Result = , Code = row.Value, Url = "", RedemptionUrl = "" }. + +##### `GET api/storefronts/v1/adcarouselitems` — STUB (cosmetic) + +Returns a hardcoded empty array. Shape-safe (client parses an empty List and simply shows no banner) and documented as deliberate after a duplicate-AdCarouselItemId crash, but the shop's promo carousel is permanently empty. + +Handler: `DorkNet.Server/Controllers/API/Store/StorefrontsController.cs:70` + +**Fix.** If banners are wanted: emit rows {AdCarouselItemId (unique Int32), ImageName, Title, Description, PurchasableItemIds:[Int32], PurchaseReminderId:null}. + +##### `GET subscription/top/creators/today` — SHAPE_MISMATCH (cosmetic) + +GET exists; rows carry AccountId/SubscriberCount in both casings but omit ClubId, which the client's HHOCDLAFOKB also reads — every 'top creator' row has ClubId 0, so opening a top creator's club from the discovery list fails. List itself renders. + +Handler: `DorkNet.Server/Controllers/API/PlayerSubscriptions/PlayerSubscriptionsController.cs:54` + +**Fix.** Join each top AccountId to its creator club (db.Clubs by CreatorPlayerId) and add ClubId/clubId to the per-row dictionary. + +##### `POST api/AppIntegrity/v1/iosproducts` — FIXED (was VERB_MISMATCH) + +Server registered GET only; the client POSTs a raw JSON product array (verb `rdx=2` at GAAPALMODOL.txt:106, route literal :104, raw-JSON body via BNDIAONDFFF.FJLLPHFOOJJ :138) and expects {Success:Boolean,Message:String} (PHMHCPEMABG.txt:3/23, keys GBPDOLJBABB.txt:191-218). The POST 405'd, and the GET body (list of {ProductId,Price,CurrencyType,Name}) is not the expected shape either. iOS-only StoreKit report — never fired by the PC build. + +Handler: `DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs` (POST) — the pre-existing GET at `DorkNet.Server/Controllers/API/AppIntegrity/AppIntegrityController.cs:13` is left in place; the two verb constraints are disjoint so they do not collide. + +**Fixed.** The POST reads the top-level array of {Name:String,Price:Single,ProductId:String} (GAAPALMODOL/KOLPHINEHDD, key table KAONMCEONPF.txt:255-306) and stores the reported StoreKit catalogue as the caller's `appintegrity:iosproducts` PlayerSettings row (`{productId}={price}` pairs, entries dropped once the 1024-char column budget is spent), replacing any earlier report. Returns {Success:true, Message:""}; a malformed body returns Success=false with Message `malformed_product_list`. + +### Progression, quests, leaderboards and misc + +`progression-misc` + +Verified all 30 real HTTP routes of progression-misc against DorkNet source. 24 are OK with exact wire shapes (most live-validated on the march-2023 branch). Defects: (1) BREAKS-GAMEPLAY — POST leaderboard/CheckAndSetStat is not registered anywhere (grep-confirmed); the 2023 client has no SetStat literal, so every leaderboard score write is lost to the catch-all. (2) PUT rooms/{roomId}/playerdata/me (form field 'data') is missing — per-room player save blobs never persist, and the existing GET returns a hardcoded \"CAE=\" stub blob. (3) api/freegifts/v1/sendmultiple never reads the client's ToPlayerIds key → always 400 missing_recipients. (4) api/objectives/v1/cleargroup binds [FromForm(\"group\")] but the client sends JSON {\"Group\":int} → always clears/echoes group 0. (5) GET api/incentivizedreferrals/ serves the progress shape instead of the referral-list DTO, and /claim returns a slim [{rewardSelectionId, rewardType:\"Currency\"}] that conflicts with the proven GLOFCEJBIGB shape (\"Currency\" is not an LJGADMBNNMC enum member). (6) UNKNOWN/at-risk: /incentivizedreferrals/progress (client expects int + milestone list; server emits 6 scalars) and subscriptionseasons/current (client DTO starts with a Guid; server sends \"yyyyMM\") — both need JSON keys extracted from client metadata before they can be authored correctly. + +**Client-side notes.** HOW REQUESTS ARE BUILT (applies to whole subsystem): every call goes through builder BNDIAONDFFF (recnet-runtime-decomp/BNDIAONDFFF.cs). Its ctor (RVA 0x30036A0, seen as `Call 0x1830036A0`) takes (BestHTTP.HTTPMethods verb in rdx: 0=GET,2=POST,3=PUT,4=DELETE; GJDLNNLKDIJ host enum in r8; route string in r9). Host enum (GJDLNNLKDIJ.cs): 0=Auth, 1=API, 3=Matchmaking, 9=Leaderboard, 26=Econ. Subsystem host split: PlayerCheer/charades/communityboard/quickPlay -> API host; challenge/checklist/objectives/gamerewards/freegifts/referrals/influencer/wishlists/royale/seasons/earnings -> Econ host (26) — DorkNet must answer these on whatever subdomain maps to Econ (or a catch-all host); leaderboard/* -> Leaderboard host (9); invite/{id} DELETE -> Matchmaking host; role/{role}/{id} -> Auth host. AFGEDDANEKP(key,value) = form-urlencoded field for POST / query param for GET; FJLLPHFOOJJ(string) = raw JSON body; KDOPJCNKOOK = fire-and-forget send; FDKKOPAPDGF/AMMGOPHAAAC = typed JSON sends. Request DTOs serialized with JsonUtility or Newtonsoft KEEP original C# names (ObjectiveGroupRequest.Group, ChecklistCompletionDTO.ItemIndex, MatchCompleteStats.*, SetStatRequestDTO.*, MultiRecipientFreeGiftRequestDTO.*) — these exact PascalCase keys are on the wire. Response DTO property names are obfuscated and their JSON keys live in metadata attributes (NOT visible in Cpp2IL ISIL/decomp); the server's per-endpoint doc comments cite the client's generated readers (which probe Pascal/camel/lower variants) and most are live-validated on the march-2023 branch. GAPS/DISCREPANCIES FOUND (server work needed): (1) leaderboard/CheckAndSetStat is NOT registered — 2023 client has no SetStat literal, so all 2023 stat writes 404; add POST /leaderboard/CheckAndSetStat (+ /api/ prefix) accepting SetStatRequestDTO JSON {StatChannel,RoomId,StatValue,CurrentStatValue?} and returning enum scalar 0/"Success". (2) PUT rooms/{roomId}/playerdata/me (form field `data`) is not registered — only the GETs are — so per-room player save data never persists. (3) api/objectives/v1/cleargroup binds [FromForm(Name="group")] but the 2023 client sends JSON {"Group":int} — group always binds 0. (4) api/freegifts/v1/sendmultiple field probe list lacks ToPlayerIds/toPlayerIds (client's preserved DTO key) — recipients parse empty -> 400. (5) GET api/incentivizedreferrals/ (list, take+continuationToken -> {entries,continuation}) is served by the progress-shaped handler; and /progress's PAJCMPNMKCL {int, List} shape is unverified. (6) api/subscriptionseasons/v1/seasons/current: client DTO IGBGJKMGDDK starts with a Guid; server emits a "yyyyMM" string SeasonId — type-mismatch risk, unverified. Cache-key literals mistaken for routes in groups.json: ownedby/{0}, player_room_data/{0}, api/itemWishlists/v1/wishlist/me, search/, search_live/ (real routes documented per entry); format-arg prefixes: api/itemWishlists, api/roomEarningsDistributions. + +#### Endpoints + +| Verb | Route | Request | Response | +|---|---|---|---| +| POST | `api/PlayerCheer/v1/SetSelectedCheer` | form-urlencoded: CheerCategory (Nullable enum; omitted/empty clears selection) | body ignored (LDGADANDBIO fire-and-forget task); server returns {success:true,error:""} | +| POST | `api/PlayerCheer/v1/create` | form-urlencoded fields: PlayerIdTo (Int64), CheerCategory (Int32 enum), RoomId (Int64, only when in a room), Anonymous (Boolean) | object {success:bool, message:string} — JSON keys obfuscated in binary; DorkNet dual-cases Success/success, Message/message, Error/error via Dictionary (validated live) | +| GET | `api/activities/charades/v1/words/{0}` | none; {0} = card-source enum name formatted into path (String.Format of boxed enum) | JSON array of GEFMIBEPMKJ; DorkNet serves [{EN_US:string, Difficulty:int}] from CharadesWordListService (validated live per charades memory/admin library) | +| GET | `api/challenge/v2/getCurrent` | none | object; DorkNet emits {ChallengeMapId:int, CompletedRequired:bool, StartAt/EndAt/ServerTime:DateTime, FallbackGiftName:string, ChallengeThemeString:string, Challenges:[{ChallengeId,Name,Config,Description,Tooltip,Complet | +| POST | `api/challenge/v2/updateProgress` | raw JSON body via BNDIAONDFFF.FJLLPHFOOJJ (Newtonsoft SerializeObject at 0x1817F9920); exact key literals in metadata attributes (not in ISIL). DorkNet binds Id/ChallengeMapId/Chal | body ignored (KDOPJCNKOOK = send-with-no-response) | +| POST | `api/checklist/v1/complete` | JSON body {"ItemIndex": Int32} (DTO name + property preserved in decomp) | gift/balance-update object; DorkNet returns BalanceUpdateResponse(balance, RecCenterTokens, credit, 303) — validated live | +| GET | `api/checklist/v1/current` | none | bare JSON array; DorkNet emits [{Order:int, Objective:int, Count:int, CreditAmount:int}] (superset of the 3 client fields; extras ignored) — validated live | +| GET | `api/communityboard/v2/current` | none | object with board sections; DorkNet emits InstagramImages:[{ImageName,ImageUrl}], FeaturedRoomGroup:{FeaturedRoomGroupId,Name,Rooms:[{RoomName,RoomId,ImageName}]}, video/thumbnail fields (CommunityBoardController documen | +| POST | `api/freegifts/v1/sendmultiple` | raw JSON body = Newtonsoft-serialized MultiRecipientFreeGiftRequestDTO: {"ToPlayerIds":[int], "Message":string, "GiftContext":int} | body ignored (fire-and-forget); server returns {Success,Sent} | +| GET | `api/gamerewards/v1/pending` | none | bare JSON array of reward selections; DorkNet emits [{RewardSelectionId:long, Message:string, GiftContext:int, RewardType:int, GiftDrop1/2/3:EFFIEFEFHHB-shaped store gift drops (AvatarItemId Int32 strict), CreatedAt:Date | +| POST | `api/gamerewards/v1/request` | form-urlencoded: rewardType (enum), Message (string, capital M), giftContext (enum, only when non-null) | body ignored (LDGADANDBIO); server returns {Success,Error} | +| POST | `api/gamerewards/v1/select` | form-urlencoded: rewardSelectionId (Int64, from GLOFCEJBIGB.FEFHNOHNMJL), giftDropId (Int32) | body ignored (LDGADANDBIO); server returns {Success,Error} and grants the item | +| GET | `api/incentivizedreferrals/` | query: take (Int32, optional), continuationToken (string, optional) | object {referral-entry list + continuation token} — exact JSON keys UNKNOWN (obfuscated properties, no reader literals located). DISCREPANCY: DorkNet maps GET api/incentivizedreferrals ('' route) to the same progress-sha | +| POST | `api/incentivizedreferrals/claim` | form-urlencoded: ReferralRewardId (Int32, PascalCase) | bare JSON array of reward selections (empty array = nothing to claim; an object crashes the strict array reader per server doc). DorkNet returns [{rewardSelectionId:long, rewardType:string}] | +| GET | `api/incentivizedreferrals/progress` | none (cached client-side via AMDPEBKIHOH TimeSpan) | object {int + milestone list}; exact JSON keys UNKNOWN (obfuscated). DorkNet emits {ReferralCount,RequiredReferralCount,CanClaim,Claimed,RewardCurrencyType,RewardCurrency} — shape not verified against the 2023 reader | +| GET | `api/influencerpartnerprogram/influencer` | query: accountId (Int32) | bare JSON Int32; 0 means 'not an influencer' (client converter maps 0 -> null); empty body throws 'Response was empty' | +| GET | `api/influencerpartnerprogram/influencers` | query: take (Int32, client sends 1000), continuationToken (string, omitted when null) | object {InfluencerIds:int[], ContinuationToken:string\|null} — MUST be an object not a bare array; non-null ContinuationToken triggers recursive next-page fetch (server doc cites reader FLEDNKHKIND.txt:215-265 registerin | +| GET | `api/influencerpartnerprogram/myinfluencer` | none | bare JSON Int32; 0 = supporting nobody (converter maps 0 -> null) | +| POST | `api/influencerpartnerprogram/remove` | form-urlencoded: influencerAccountId (Int32) | body ignored (fire-and-forget) | +| POST | `api/influencerpartnerprogram/support` | form-urlencoded: influencerAccountId (Int32) | body ignored (fire-and-forget) | +| GET | `api/itemWishlists/v1/wishlist/` | none; accountId concatenated onto path -> GET api/itemWishlists/v1/wishlist/{accountId} | bare JSON array of BFJNGMGONED (empty array valid; 404/empty body crashes with 'Response was empty') | +| POST | `api/objectives/v1/cleargroup` | raw JSON body via JsonUtility.ToJson: {"Group": Int32} | object; DorkNet emits {Group:int, IsCompleted:bool, ClearedAt:string(ISO), RequiresCompleteOnServer:bool, IsRewarded:bool}. DISCREPANCY: server binds [FromForm(Name="group")] (ProgressionController.cs:386-390) but the 20 | +| POST | `api/objectives/v1/completegroup` | raw JSON body {"Group": Int32} (JsonUtility) | object; DorkNet emits {Group:int, IsCompleted:bool, ClearedAt:string, RequiresCompleteOnServer:bool, IsRewarded:bool, Rewarded:bool} via [FromBody] binding — validated live | +| GET | `api/objectives/v1/myprogress` | none | object; DorkNet emits {Objectives:[{Index:int,Group:int,Progress:float,VisualProgress:float,IsCompleted:bool,HasClaimedReward:bool}], ObjectiveGroups:[{Group,IsCompleted,ClearedAt,RequiresCompleteOnServer,IsRewarded}]} — | +| POST | `api/objectives/v1/updateobjective` | raw JSON body (Newtonsoft serialize at 0x1817F9920 -> FJLLPHFOOJJ); DorkNet binds UpdateObjectiveRequest {Group,Index,Progress,IsCompleted} case-insensitively | loosely handled — client uses FPCPAJAAHME (raw response, no strict typed parse); DorkNet returns {Index,Group,Progress,VisualProgress,IsCompleted,HasClaimedReward} | +| GET | `api/quickPlay/v1/getandclear` | none | object with OBFUSCATED keys — DorkNet's QuickPlayLaunchTarget emits {OKCGKFJELAC:bool, INMKAFBAOPC:string, FIOPJIIOCGA:int?, ICKOEFDNOPM:string, KLDCJBLLDKN:int?, OFBMKDAONNE:bool, FMKHKNCEBCN:object?, EEFNKAFLPLG:long?} | +| POST | `api/roomEarningsDistributions/v1/earningsDistribution` | raw JSON body = serialized distribution {RoomId:long, mapping Dictionary, method enum} (Newtonsoft; exact key literals in metadata — DorkNet reads Room | the saved distribution object (same shape as GET); server echoes BuildDto(roomId, method, mapping) | +| GET | `api/royale/v1/current` | none | object {TotalXP:long, Level:int, RankIdx:int, RankName:string, CurrentLevelXPThreshold:long, NextLevelXPThreshold:long, NextLevelAcornReward:int} (CLR types match PLCAPKKIEAK exactly; validated live) | +| POST | `api/royale/v2/matchcomplete` | raw JSON body (JsonUtility.ToJson): {"Rank":int, "NumEliminations":int, "SecondsAlive":int, "WalkGame":bool, "CustomGame":bool, "ChestsOpened":int, "ShieldPotionsConsumed":int, "He | object {XPAwardStrings:List, TotalXPAwarded:long, NewProgress:[PLCAPKKIEAK]} — CLR types match MFDLADFOKPN exactly; validated live | +| GET | `api/subscriptionseasons/v1/seasons/current` | none (client caches with TimeSpan.FromHours) | object; exact JSON keys UNKNOWN (obfuscated properties). RISK: first property is a Guid — DorkNet's SubscriptionSeasonsController emits SeasonId as "yyyyMM" string plus {Name, StartAt, EndAt, ActiveSubscriberCount, Rewar | +| DELETE | `invite/{0}` | none; {0} = invite/message id in path. Issued against host 3 = Matchmaking (match.* subdomain) | body ignored (KDOPJCNKOOK fire-and-forget) | +| POST | `leaderboard/CheckAndSetStat` | raw JSON body = Newtonsoft-serialized SetStatRequestDTO: {"StatChannel":int, "RoomId":long, "StatValue":int, "CurrentStatValue":int\|null} | deserialized into enum KLLFFIDADCN — a bare JSON scalar works (0 or "Success"); Newtonsoft enum reader accepts int or name string. GAP: DorkNet registers only leaderboard/SetStat (LeaderboardController.cs:75-76); the 202 | +| POST | `leaderboard/GetNearbyScores` | raw JSON body (closure pre-serializes DTO to string, passed via FJLLPHFOOJJ): keys are the preserved DTO field names above | single-list wrapper; DorkNet emits {rows:[{playerId:int, score:long, rank:int}]} (2020-validated SingleLeaderboard shape; must be an object with the list key, never a bare array) | +| POST | `leaderboard/GetPlayerRank` | raw JSON body = serialized GetRankRequestDTO (preserved field names) | single entry object; DorkNet emits {playerId:int, score:long, rank:int} (camelCase per its FullLeaderboard.Entry evidence; zero-entry {playerId:0,score:0,rank:0} valid) | +| POST | `leaderboard/GetRanks` | raw JSON body = serialized GetRanksRequestDTO (preserved field names) | {rows:[{playerId:int, score:long, rank:int}]} wrapper (same as GetNearbyScores) | +| GET | `role/{0}/{1}` | none; path = role/{roleName-UriEscaped}/{accountId}; roleName literals passed by the two call sites are "developer" and "moderator". Issued against host 0 = Auth (auth.* subdomain) | bare JSON true/false | + +#### Not HTTP routes + +These literals look like paths but are cache keys, deeplinks or MIME types: + +- `api/itemWishlists` — Add/remove item on own wishlist from the store item detail page +- `api/itemWishlists/v1/wishlist/me` — Cache invalidation after wishlist add/remove +- `api/roomEarningsDistributions` — Opening the room co-owner earnings-split settings page +- `ownedby/{0}` — 'My Rooms' / another player's created-rooms list (cached) +- `player_room_data/{0}` — Per-room player save data (room progress blob) load on room join / save on change +- `search/` — Player-events search on the watch events tab (cached) +- `search_live/` — Live/ongoing player-events search (cached) + +#### Defects + +##### `POST leaderboard/CheckAndSetStat` — MISSING (breaks-gameplay) + +CONFIRMED absent: repo-wide grep for 'CheckAndSetStat' matches only docs and client-routes.txt — no controller registers it. LeaderboardController only has POST /leaderboard/SetStat (LeaderboardController.cs:75-76), a literal the 2023 GMHDPPLGMDP does not contain. The 2023 client's only stat-write route therefore falls through to GlobalCatchAllController (200 {} per Data/url-coverage.md), the enum deserialize of '{}' into KLLFFIDADCN fails, and NO leaderboard score from the 2023 client is ever persisted (Stunt Runner times etc. silently lost). Note the existing SetStat ack {success:true,error:""} would ALSO be wrong for this caller — the 2023 response type is the bare enum scalar. + +**Fix.** Add [HttpPost("/leaderboard/CheckAndSetStat")] (+"/api/leaderboard/CheckAndSetStat") to LeaderboardController binding a DTO {StatChannel:int, RoomId:long, StatValue:int, CurrentStatValue:int?} (extend SetStatRequest with CurrentStatValue), reuse the SetStat upsert logic, and return the bare enum scalar — Content("0", "application/json") for Success. + +##### `POST api/freegifts/v1/sendmultiple` — SHAPE_MISMATCH (degraded) + +Request-side key mismatch, CONFIRMED: the 2023 client's Newtonsoft body uses the preserved DTO key ToPlayerIds (RecNet.MultiRecipientFreeGiftRequestDTO), but ReadLongList at line 22 probes only recipientPlayerIds/RecipientPlayerIds/playerIds/PlayerIds. The JSON reader (ReadFieldsAsync) does capture 'ToPlayerIds' into the case-insensitive field dict, but no probed name matches it, so recipients parse empty and line 23 returns 400 'missing_recipients' — every free-gift send from the 2023 client fails. Message binds fine ('Message' matches case-insensitively); GiftContext is not read (gift stored with GiftContext=0, minor). Response body is ignored by the client so the 400 surfaces only as a failed send. + +Handler: `DorkNet.Server/Controllers/API/FreeGifts/FreeGiftsController.cs:17` + +**Fix.** In FreeGiftsController.cs:22 add "ToPlayerIds" (and "toPlayerIds") to the ReadLongList probe list; optionally also read GiftContext into the stored gift. + +##### `GET api/incentivizedreferrals/` — SHAPE_MISMATCH (degraded) + +CONFIRMED route-shape confusion: [HttpGet("")] and [HttpGet("progress")] share one handler returning the progress object {ReferralCount,RequiredReferralCount,CanClaim,Claimed,RewardCurrencyType,RewardCurrency}. The client's GET on the bare route expects the referral LIST wrapper GPJIMAHMAIO {List entries + continuationToken} and passes take/continuationToken query params, which the handler ignores. The client gets an object with no list key — the entries list binds missing (default/null), so the refer-a-friend list page shows nothing or NREs. Exact client JSON keys are UNKNOWN (obfuscated properties, no reader literals located), so the correct emission cannot be fully authored yet. + +Handler: `DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs:18` + +**Fix.** Split GET "" into its own handler accepting take+continuationToken and returning the list DTO; first extract JIMHNOHLOFM/GPJIMAHMAIO JSON keys from the client's metadata-attribute readers (global-metadata / generated reader dump), then emit those keys (dual-cased via Dictionary if ambiguous). + +##### `POST api/incentivizedreferrals/claim` — SHAPE_MISMATCH (degraded) + +Client deserializes the response as List — the SAME reward-selection DTO as gamerewards/pending, whose working live-validated wire shape is {RewardSelectionId,Message,GiftContext,RewardType(int),GiftDrop1/2/3,CreatedAt}. The server instead returns [{rewardSelectionId, rewardType:"Currency"}]: (a) 'Currency' is not a member of the LJGADMBNNMC enum (FirstActivityOfDay..ReferralReward) so an enum-name parse fails; (b) all other GLOFCEJBIGB fields (Message, GiftContext, GiftDrops, CreatedAt) are absent and will crash if the strict reader requires them. Empty-array no-op path is correct. Also ignores the client's ReferralRewardId form field (harmless — single reward). PLAUSIBLE-crash, not live-verified. + +Handler: `DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs:43` + +**Fix.** Rebuild the claim response using the same ToWire shape as GameRewardsController (RewardSelectionId long, RewardType=6 int for ReferralReward, Message, GiftContext, GiftDrop1-3 via StoreService.BuildGiftDrop or null, CreatedAt) — ideally create a real GameRewardSelection row and return it. + +##### `GET api/incentivizedreferrals/progress` — UNKNOWN (degraded) + +Handler exists on the right verb, but the client DTO PAJCMPNMKCL is {int, List} (an int plus a MILESTONE LIST whose rows are {DateTime?,DateTime?,enum,enum,bool}), while the server emits six scalars ({ReferralCount,RequiredReferralCount,CanClaim,Claimed,RewardCurrencyType,RewardCurrency}) and no list at all. The obfuscated JSON keys are unlocated so a definitive verdict is impossible, but a required list key missing would make the referral progress meter empty or crash the strict reader. Unverified against the 2023 reader. + +Handler: `DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs:18` + +**Fix.** Extract PAJCMPNMKCL/ALOFEHAHGII JSON keys from client metadata, then emit {count, milestones:[{start?,end?,enumA,enumB,claimed}]} under those keys; keep the current shape only if metadata proves the reader tolerates missing keys. + +##### `POST api/objectives/v1/cleargroup` — SHAPE_MISMATCH (degraded) + +Request-side binding mismatch, CONFIRMED: handler binds only [FromForm(Name="group")] with no JSON fallback, but the 2023 client sends a raw JSON body {"Group":int} (JsonUtility.ToJson of ObjectiveGroupRequest). With a JSON content type the form provider is never populated, group binds null, and the handler clears/rewards group 0 and echoes Group=0 regardless of which group the player claimed — daily-objective group claims hit the wrong group and the echoed ObjectiveGroupProgress desyncs the watch's group state. + +Handler: `DorkNet.Server/Controllers/API/Progression/ProgressionController.cs:386` + +**Fix.** Change ClearGroup to accept the JSON body: bind [FromBody] CompleteGroupBody (like completegroup) with a form fallback for older clients, or reuse the ReadChallengeRequestAsync-style dual reader. + +##### `GET api/subscriptionseasons/v1/seasons/current` — UNKNOWN (degraded) + +Handler exists on the right verb, but the response is unverified against the 2023 strict reader and has a concrete type-mismatch risk: client DTO IGBGJKMGDDK is {Guid, string, string, DateTime, DateTime?, List} while the server emits SeasonId="yyyyMM" (NOT parseable as a Guid), only one name string, plus ActiveSubscriberCount (extra) and Rewards rows ({RewardId,Slug,Name,Category,ImageName}) whose shape was never checked against AEDILOEPMFC. If the reader Guid-parses the season id or requires the second string/reward keys, the Rec Room Plus season UI errors. JSON keys are obfuscated and unlocated — cannot confirm either way. + +Handler: `DorkNet.Server/Controllers/API/SubscriptionSeasons/SubscriptionSeasonsController.cs:10` + +**Fix.** Emit the season id as a real stable Guid string (e.g. deterministic Guid from yyyyMM) and add a second string field; extract IGBGJKMGDDK/AEDILOEPMFC JSON keys from client metadata before finalizing the reward-row shape. + +##### `PUT rooms/{roomId}/playerdata/me` — MISSING (degraded) + +The 2023 client saves its per-room player blob via PUT rooms/{roomId}/playerdata/me with form field data= (NLDBPDCNNCF.txt:2566-2586, verb 3 PUT; form key at NLDBPDCNNCF_NestedType_ANPKFGHOOAJ.txt:57). Grep confirms the server registers only the GETs ([HttpGet("player_room_data/{roomId:long}")] + [HttpGet("rooms/{roomId:long}/playerdata/me")] at :2307-2308) — the PUT falls to the catch-all and per-room save data never persists. Compounding it, the GET is a partial STUB for this client: it returns a hardcoded Data="CAE=" blob (:2331) rather than anything previously saved, so even after adding the PUT the round-trip needs real storage. + +Handler: `DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs:2307 (GET only)` + +**Fix.** Add [HttpPut("rooms/{roomId:long}/playerdata/me")] reading form field 'data' and persisting it per (playerId, roomId) (new entity or PlayerSettings key), and change PlayerDataForMe to return the stored blob in Data (falling back to "CAE=" when none), keeping the existing extra keys. + +### Moderation, reporting and safety + +`moderation-safety` + +18 client endpoints audited against DorkNet server source (all handlers read directly). 10 are fine. Breaking defects: POST api/playerwarnings gets 405 (server registers GET only); POST api/playerwarnings/acknowledge always 400s (server demands warningId the 2023 client never sends); api/banappeal/generateCode is POST-only server-side while the client GETs it (405) and both generateCode endpoints return JSON objects where the client deserializes a bare string; api/PlayerReporting/v1/instantKick never reads the client's raw-JSON KickPlayerDTO body so every kick no-ops with success:false; v3/voteToKick is an acknowledged stub that tallies nothing. Data-loss defects: screensharereports binds targetPlayerId/reportCategory names the client never sends (report stored with no target/image), and clubreporting reads the report text from "message" while the client sends "details" (text dropped). + +**Client-side notes.** All 19 literals in the moderation-safety group are real HTTP requests (none are cache keys/deeplinks). The four v1/{mute,unmute,ignore,unignore} literals are suffixes concatenated with "api/relationships/" at EEGNOHOELBG.txt:4182. Verb encoding proven from BNDIAONDFFF..ctor(BestHTTP.HTTPMethods, GJDLNNLKDIJ, String) (BNDIAONDFFF.txt:74): rdx 0=GET, 2=POST; r8 is a host-selector enum (1=main api host everywhere here except the two voice/* routes which use 16 = the voice/ToxMod service host — DorkNet must keep serving them at root path). Request bodies are x-www-form-urlencoded via BNDIAONDFFF.AFGEDDANEKP(key,value) with exact key literals in the ISIL, EXCEPT v1/instantKick which posts raw JSON (JsonUtility.ToJson of RecNet.KickPlayerDTO via FJLLPHFOOJJ). Response DTO JSON keys are Newtonsoft-attribute metadata and never appear in the ISIL — only field counts/types are recoverable (established DorkNet workaround: dual-cased dictionaries; Newtonsoft matches case-insensitively). SERVER ACTION ITEMS found while auditing (all verified against controller source): (1) POST api/playerwarnings is 405 — server only registers GET (PlayerWarningsController.cs:13); the 2023 client uses POST to create moderator warnings. (2) POST api/playerwarnings/acknowledge always 400s — client sends no warningId (PlayerWarningsController.cs:31-32 requires it); should acknowledge the pending warning implicitly. (3) api/banappeal/generateCode is called with GET but server is POST-only (CompatibilityFeatureController.cs:15) → 405; and both generateCode endpoints must return a bare JSON string, not an object (AgeVerificationController.cs:29 / CompatibilityFeatureController.cs:28) — client deserializes TResponse=String. (4) api/PlayerReporting/v1/instantKick: 2023 client sends a JSON KickPlayerDTO body which the form/query bindings never read (InGameModerationController.cs:116-134) → kick silently no-ops with success:false. (5) api/screensharereports/v1/report: server binds targetPlayerId/reportCategory which the client never sends; real keys are ImageName/ReportedPlayerId/RoomId/RoomInstanceId/RoomInstanceType/Details (ScreenShareReportsController.cs:15-18). (6) api/clubreporting/v1/report: report text arrives under "details" but server reads "message" (CompatibilityFeatureController.cs:158) → text dropped. Non-breaking deltas: v3/create's RoomInstanceType and voteToKick's Reason are sent but unbound; hile's ReportedPlayer is unpersisted; moderationBlockDetails' 2023 DTO gained ~5 fields (extra bools, string, DateTime?, float) whose keys are unknown but default safely. + +#### Endpoints + +| Verb | Route | Request | Response | +|---|---|---|---| +| POST | `api/PlayerReporting/v1/deviceId` | form fields: oldDeviceId=String, newDeviceId=String (current device id from static getter), platform=Int32 (HHJIBNMLOAC enum). Note lowercase keys. | Body ignored — client uses BNDIAONDFFF.KDOPJCNKOOK (non-generic Task); any 2xx suffices. | +| POST | `api/PlayerReporting/v1/hile` | form fields: Message=String, Type=Int32 (HHKINDNBLEE cheat-type enum), ReportedPlayer=boxed Nullable (omitted/empty when null) | Bare JSON boolean (true/false) — parsed via BNDIAONDFFF.FDKKOPAPDGF, then Action continuation. | +| POST | `api/PlayerReporting/v1/instantKick` | RAW JSON body (NOT form): UnityEngine.JsonUtility.ToJson of RecNet.KickPlayerDTO with field at offset16=Int64 game session id (same Matchmaking+16 field posted as GameSessionId els | PHMHCPEMABG { Boolean, String }. Failure toast: "Failed instant kick". | +| GET | `api/PlayerReporting/v1/moderationBlockDetails` | none | Single JSON object (NOT an array) deserialized into PIBLFGPOCIB. 2023 DTO has 12 properties (getters in PIBLFGPOCIB.txt): report-category enum (FPIBGPIAOBI/BDOGOIGCKMK), Int32, Int64, Boolean, String, Nullable, 3x | +| GET | `api/PlayerReporting/v1/voteToKickReasons` | none | JSON array List; FINKMCHCBMP (nested in FPIBGPIAOBI) = { BDOGOIGCKMK category enum (getter CEECFKOHHHC), String reason text (getter MKDJAFJMMOL) }. Exact keys not in ISIL; server serves [{ReportCategory:int, | +| POST | `api/PlayerReporting/v3/create` | form fields (BNDIAONDFFF.AFGEDDANEKP): PlayerIdReported=Int32, ReportCategory=Int32 (BDOGOIGCKMK enum), Details=String, HeightReporter=String (float ToString "F2" InvariantCulture) | PHMHCPEMABG { Boolean success-flag (getter GJLFIFEJDEH), String message (getter ABGLLJPIMIO) }. Exact JSON keys not in ISIL (attribute metadata); Newtonsoft matches case-insensitively — DorkNet convention is dual-cased S | +| POST | `api/PlayerReporting/v3/voteToKick` | form fields: PlayerId=Int64, Response=Boolean, Reason=String, GameSessionId=Int64 (Matchmaking current session, offset +16) | PHMHCPEMABG { Boolean, String } as above. Failure toast: "Failed vote to kick". | +| POST | `api/ageverification/generateCode` | none | Bare JSON string (the code), e.g. "123456" — TResponse is String inside the IPCJLCNIBEG envelope (same parse family as hile's bare Boolean). Failure toast: "Failed to generate action code". | +| GET | `api/banappeal/generateCode` | none (gated on a logged-in check before issuing) | Bare JSON string (the appeal code) — continuation is Func, FGLDKEJLAKB>. Failure toast: "Failed to generate action code". | +| POST | `api/clubreporting/v1/report` | form fields (note camelCase, unlike PlayerReporting): clubId=Int64, reportCategory=Int32, details=String | PHMHCPEMABG { Boolean, String } (continuation Func). | +| POST | `api/playerwarnings` | form fields: WarnedPlayerId=Int32, ReportCategory=Int32, DisplayReason=String, ModeratorNote=String. Gated client-side behind an ObscuredBool (moderator capability flag); non-moder | DJMHAFPGLLN { Boolean (getter GJLFIFEJDEH), String (getter BGKMGILIJCM) }. Exact keys UNKNOWN (attribute metadata); plausibly Success + WarningId given the server's acknowledge response shape. | +| POST | `api/playerwarnings/acknowledge` | none — no form fields, no query. The client acknowledges its pending warning implicitly. | DJMHAFPGLLN { Boolean, String }. Failure toast: "Failed to acknowledge warning". | +| POST | `api/screensharereports/v1/report` | form fields: ImageName=String, ReportedPlayerId=Int32, RoomId=Int64 (Matchmaking+24), RoomInstanceId=Int64 (Matchmaking+16), RoomInstanceType=Int32 (APFAHOMCEHP enum), Details=Stri | PHMHCPEMABG { Boolean, String } (continuation Func). | +| POST | `v1/ignore` | full route api/relationships/v1/ignore; form field PlayerId=Int32 | Body ignored; any 2xx. | +| POST | `v1/mute` | Literal is a suffix: full route = String.Concat("api/relationships/", "v1/mute"). Form field: PlayerId=Int32. | Body ignored (BNDIAONDFFF.KDOPJCNKOOK non-generic Task; the returned Boolean is a local optimistic value). Any 2xx suffices. | +| POST | `v1/unignore` | full route api/relationships/v1/unignore; form field PlayerId=Int32 | Body ignored; any 2xx. | +| POST | `v1/unmute` | full route api/relationships/v1/unmute; form field PlayerId=Int32 | Body ignored; any 2xx. | +| GET | `voice/config` | none. NOTE: issued against host enum GJDLNNLKDIJ=16 (a separate voice-service base URL, not the value-1 api host used by everything else in this subsystem). | JSON object deserialized into MCNMMGPAJPL/FOACAAFJOIK = { String (getter ADDIBBGJMJO), String (getter KCEEJCOHCKE) } — two strings, plausibly endpoint + API key; exact keys UNKNOWN (attribute metadata, no literals in ISI | +| GET | `voice/requiresModeration` | none. Same voice host enum 16. | Bare JSON boolean (true/false) via BNDIAONDFFF.FDKKOPAPDGF. | + +#### Defects + +##### `POST api/PlayerReporting/v1/instantKick` — SHAPE_MISMATCH (breaks-gameplay) + +Request-body mismatch: the 2023 client posts a RAW JSON body — JsonUtility.ToJson(RecNet.KickPlayerDTO) = presumed {"GameSessionId":long,"PlayerIds":[int]} (exact field spelling UNKNOWN, but definitely JSON, not a form) via BNDIAONDFFF.FJLLPHFOOJJ. The server only reads [FromQuery playerId], [FromForm PlayerId], [FromForm PlayerIds] (:117-119); with a JSON body none of these bind, ids stays empty, and :134 answers {success:false,error:"missing_target"} — every moderator instant-kick silently no-ops (client shows 'Failed instant kick'). + +Handler: `DorkNet.Server/Controllers/API/Moderation/InGameModerationController.cs:114-144` + +**Fix.** In InGameModerationController.InstantKick, when !Request.HasFormContentType read Request.Body as JSON (case-insensitive) into a DTO with long GameSessionId + List PlayerIds (also tolerate playerIds/gameSessionId casings since exact JsonUtility field names are unverified — accept any member whose name matches case-insensitively) and merge those ids into the kick list before the ids.Count==0 check. + +##### `POST api/PlayerReporting/v3/voteToKick` — STUB (degraded) + +Handler exists, accepts POST, binds PlayerId/Response/GameSessionId, and returns {success:true,error:""} — the wire contract is satisfied so no toast fires — but the comment at :99-102 admits it accepts the vote 'without side-effects': no per-(session,target) tally, no threshold, no kick ever issued. Vote-to-kick therefore silently never kicks anyone. Client-sent 'Reason' form field is also unbound (cosmetic). + +Handler: `DorkNet.Server/Controllers/API/Moderation/InGameModerationController.cs:88-104` + +**Fix.** In InGameModerationController.VoteToKick, tally yes-votes per (gameSessionId, playerId) (in-memory or DB), and once a majority/threshold of the room instance's players vote yes, invoke notifications.KickPlayerAsync(target, reason) like InstantKick does (:141). Also bind [FromForm(Name="Reason")] and pass it into the kick message. + +##### `POST api/playerwarnings` — VERB_MISMATCH (degraded) + +The 2023 client only POSTs this route (moderator creates a warning: form WarnedPlayerId/ReportCategory/DisplayReason/ModeratorNote) — but the server registers [HttpGet("api/playerwarnings")] only, so the POST gets HTTP 405 and warning creation always fails (client shows local failure DJMHAFPGLLN). The existing GET list handler is a 2020-era shape the 2023 client never calls. Grep confirms no other file registers this route. + +Handler: `DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs:13` + +**Fix.** Add [HttpPost("api/playerwarnings")] CreateWarning to PlayerWarningsController binding [FromForm] WarnedPlayerId(int)/ReportCategory(int)/DisplayReason(string)/ModeratorNote(string), gate on IsAdmin||IsDeveloper, store a warning row (existing PlayerSettings 'warning:{id}' scheme with Value 'message|category|createdAt|false'), push a warning notification to the target, and return the DJMHAFPGLLN-compatible object {Success:true, WarningId:"..."} (exact 2023 keys UNKNOWN — case-insensitive bool 'Success' is the established safe bet, matching the acknowledge handler's shape at :39). + +##### `POST api/playerwarnings/acknowledge` — SHAPE_MISMATCH (degraded) + +Route+verb exist, but the 2023 client sends NO body and NO query (implicit 'acknowledge my pending warning') while the server requires warningId and returns BadRequest("missing_warning") at :32 when absent — so every acknowledge 400s, the promise rejects, and the 'Failed to acknowledge warning' toast fires with the warning never marked read (modal re-appears every login). + +Handler: `DorkNet.Server/Controllers/API/PlayerWarnings/PlayerWarningsController.cs:27-40` + +**Fix.** In PlayerWarningsController.Acknowledge, when no warningId is supplied, fall back to the caller's newest unacknowledged 'warning:*' PlayerSettings row (OrderByDescending(Id), Acknowledged==false), mark it acknowledged, and return {Success:true, WarningId:...}; return {Success:true} (not 4xx) even when there is nothing pending so the modal always dismisses. + +##### `POST api/ageverification/generateCode` — SHAPE_MISMATCH (degraded) + +Verb OK (POST+GET both registered), but the response is a JSON object {Code,VerificationCode,ExpiresAt} (:29) while the client's TResponse is String — Newtonsoft DeserializeObject of a JSON object throws, the promise rejects, and the 'Failed to generate action code' toast fires; the age-verification code is never shown. (PLAUSIBLE severity — the throw-vs-null behavior of the client's parse helper isn't binary-proven, but an object can never yield the string the UI displays.) + +Handler: `DorkNet.Server/Controllers/API/AgeVerification/AgeVerificationController.cs:16-30` + +**Fix.** In AgeVerificationController.GenerateCode return the bare JSON string instead of an object: Content(JsonSerializer.Serialize(code), "application/json") (i.e. body '"123456"'), mirroring ReportsController.HileResult's bare-scalar pattern. + +##### `GET api/banappeal/generateCode` — VERB_MISMATCH (degraded) + +Two defects: (1) CONFIRMED verb mismatch — client calls GET (FPIBGPIAOBI.txt:2980-2982, verb rdx=0) but the server registers [HttpPost] only (:15-16) → HTTP 405, ban-appeal code generation always fails. (2) Even via POST the response {value=code} (:28) is a JSON object where the client deserializes a bare String (Func,...> continuation) — same bare-scalar problem as ageverification. + +Handler: `DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs:15-29` + +**Fix.** In CompatibilityFeatureController.GenerateBanAppealCode add [HttpGet("api/banappeal/generateCode")] alongside the existing HttpPost, and change the response to the bare JSON string body '"BA-123456"' (Content(JsonSerializer.Serialize(code), "application/json")). + +##### `POST api/clubreporting/v1/report` — SHAPE_MISMATCH (degraded) + +Request-binding mismatch: client sends form keys clubId/reportCategory/details (IKMMOCKDKAF.txt:25303-25322); the server reads clubId and reportCategory correctly but takes the report text from form["message"]/["Message"] (:158) — 'details' is never read, so every club report is stored with an empty message (only the '[club {id}]' prefix). Response {Success,Message} satisfies PHMHCPEMABG case-insensitively, so the client UI shows success while the report body is silently dropped. + +Handler: `DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs:99-124, 147-168` + +**Fix.** In ReadClubReportAsync (:158) extend the fallback chain: form["details"] / form["Details"] before or after the message keys (and add Details to ClubReportRequest for the JSON path). + +##### `POST api/screensharereports/v1/report` — SHAPE_MISMATCH (degraded) + +Request-binding mismatch: client sends form keys ImageName/ReportedPlayerId/RoomId/RoomInstanceId/RoomInstanceType/Details (KAEAEIODGBG.txt:281-360); server binds targetPlayerId/roomId/reportCategory/details (:15-18). Form binding is case-insensitive so RoomId and Details do land, but 'ReportedPlayerId' never binds to 'targetPlayerId' (target stored as 0), 'reportCategory' is never sent (defaults to 5), and ImageName/RoomInstanceId/RoomInstanceType are dropped entirely — the stored report identifies neither the offender nor the offending image. Response {Success,Error} satisfies PHMHCPEMABG, so the client shows success. + +Handler: `DorkNet.Server/Controllers/API/ScreenShareReports/ScreenShareReportsController.cs:13-39` + +**Fix.** In ScreenShareReportsController.Report bind the client's exact keys: [FromForm(Name="ReportedPlayerId")] long?, [FromForm(Name="ImageName")] string?, [FromForm(Name="RoomId")] long?, [FromForm(Name="RoomInstanceId")] long?, [FromForm(Name="RoomInstanceType")] int?, [FromForm(Name="Details")] string?; store ReportedPlayerId as TargetPlayerId and prefix/append ImageName+RoomInstanceId into the Message (or dedicated columns) so the report is actionable. + +### Config, announcements, feed and telemetry + +`config-telemetry` + +Audited all 27 real HTTP routes of the config-telemetry subsystem against DorkNet (march-2023-03-21 branch), reading every server handler and spot-verifying disputed client shapes in the ISIL dump. 11 routes are fully OK (freegiftbutton, gameconfigs, emoji whitelist, pageview/consume, testpass reads, claim/unclaim, announcement delete/read). WORST FINDINGS: (1) PUT announcements/club/{clubId}/{announcementId} — the client's EDIT — is bound to the DELETE handler (ClubsController.cs:762), so editing a club announcement silently destroys it; (2) POST announcements/club/{clubId} (create) is not registered at the collection URL, so posting announcements 404s; (3) three deserializer-breaking shape mismatches verified against client formatters: config/categories returns a {Results,TotalResults} envelope where the 2023 client needs a bare array (LELAJKMOMIA.txt:1552), settings/partyinvite returns a bare int where the client needs {InviteLinkLifetimeInMinutes} (AIEIPLDGFCF.txt:151), and GET announcements/club/{clubId} returns an array where the client needs a single {ClubId,LastReadAnnouncementId,Announcements} object (IKMMOCKDKAF.txt:1170) — plus LastReadAnnouncementId serialized as JSON null into a non-nullable Int64. MISSING ROUTES: POST feed/query (2023 home feed), all five actionlink/datalink endpoints (share-code flows), GET announcements/mine + announcements/subscription/mine, POST testcase/{id}/comment; also POST testcase/{id}/status exists but binds [FromBody] object while the client sends a bare-integer body, so it always 400s. LOSSY-BUT-BENIGN: api/config/v2 lacks StorefrontConfig/RoomKeyConfig/RoomCurrencyConfig/GiftDropId; azurespeech sends SubscriptionKey instead of the client's "Key"; amplitude lacks RudderStack/StatSig keys; backtrace lacks 7 of 9 keys; cohortnux lacks the two Description keys; announcement v1 lacks LinkButtonLabel. The 2023 tri-cased generated readers make key casing forgiving and missing keys default, so unlike 2020 none of the lossy gaps throw — the defects that matter are the wrong-handler PUT, missing routes, and array-vs-object / object-vs-scalar mismatches. + +**Client-side notes.** HOW REQUESTS ARE BUILT (applies to every endpoint here): RecNet.Runtime.BNDIAONDFFF is the shared request builder — ctor (BestHTTP.HTTPMethods verb, GJDLNNLKDIJ host, string route) per C:\tmp\recnet-runtime-decomp\BNDIAONDFFF.cs:194 and delegate line 14. In ISIL call sites the ctor is Call 0x1830036A0 with rdx=verb (0=GET, 2=POST, 3=PUT, 4=DELETE — BestHTTP order), r8=host enum, r9=route. Host enum GJDLNNLKDIJ (GJDLNNLKDIJ.cs): 1=API, 8=Chat, 10=Accounts, 11=Link, 13=Clubs, 15=PlatformNotifications, 19=Discovery. AFGEDDANEKP(key,value) = query param on GET (server's working [FromQuery] backtrace handler corroborates) and url-encoded form field on POST/PUT; FJLLPHFOOJJ(string) = raw JSON body; FDKKOPAPDGF = send expecting JSON T; KDOPJCNKOOK = send ignoring response body; AMDPEBKIHOH = client-side cache TTL. FGLDKEJLAKB is the client promise type; the generic argument at each call site IS the response DTO. + +DESERIALIZATION: every DTO has a generated formatter that registers each JSON key three times — exact PascalCase, camelCase, and all-lowercase (e.g. CDEDNEPPPND.txt:599-793) — so server key casing is forgiving for these DTOs, unlike some legacy 2020 readers. Unrecognized keys are ignored; missing keys default (no throw observed in these generated readers, unlike 2020 Util.GetKey readers). + +KEY 2023-vs-2020 DELTA: api/config/v2 reader (CONDPLKKBPI via CDEDNEPPPND) reads ONLY LevelProgressionMaps/DailyObjectives/ServerMaintenance/AutoMicMutingConfig/StorefrontConfig/RoomKeyConfig/RoomCurrencyConfig/ShareBaseUrl. The 2020-era keys DorkNet also sends (MessageOfTheDay, CdnBaseUri, PhotonConfig, MatchmakingParams, ConfigTable, ServiceUrls) are ignored by the 2023 client. New nested key: LevelProgressionMaps[].GiftDropId (Int32?). + +SERVER GAPS FOUND (routes the 2023 client can hit that DorkNet does not register, per the server's reflected route table): (1) POST actionlink, GET actionlink/{code}, POST actionlink/{code}/consume — Link host [NOW FIXED, LinkController.cs]; (2) PUT datalink, GET datalink/{code} — Link host [NOW FIXED, LinkController.cs]; (3) POST feed/query — Discovery host (the existing GET "feed" is api/photos/v1/feed, unrelated); (4) GET announcements/mine and GET announcements/subscription/mine — Clubs host; (5) POST announcements/club/{clubId} (create at collection URL — ClubsController only registers POST at .../{announcementId}); (6) POST api/testcasemanagement/v1/testcase/{id}/comment. Also verify announcements/v2/*/unread handlers honor ?sendAnnouncements=true by embedding Announcements arrays. + +SERVER SHAPE MISMATCHES (working but lossy): api/config/v1/azurespeech — client key is "Key", server sends "SubscriptionKey" (client gets null Key); api/config/v1/amplitude — client also reads RudderStackKey/UseRudderStack/StatSigKey; api/config/v1/backtrace — client's 9 keys (ANRThresholdMs, CaptureNativeCrashes, FilterType, LogLineCount, MessageCount, MessageRegex, ReportBudget, SampleRate, VersionRegex) are mostly absent from the server response (only SampleRate/ReportBudget present); all fields silently default today. + +Announcement Meta quirk: JDPPAFLFNBD's "Meta" is serialized on the wire as a JSON STRING (custom formatter MCAOGKPHCAD delegates to the String formatter), parsed client-side by MetaData.EHHMPPCEFKA; MetaData's own fields are name-preserved (Type, JsonData) so the inner JSON very likely uses those keys (inner-key casing not literal-verified — marked as inferred). + +Trigger-verification caveat: gameplay-flow attributions are grounded in the client layer itself (method/error-string evidence such as "Failed to download ConfigSettings", "GameConfig.Refresh", "GetWhitelistedDisplayNameEmojis", ExternalShareImpl) rather than full Assembly-CSharp caller walks, except feed/{0}/{1} and feed/query where RRUI callers were located. + +#### Endpoints + +| Verb | Route | Request | Response | +|---|---|---|---| +| POST | `actionlink` | form fields (url-encoded body): data=String, validHours=Int32, maxCount=Int32 (omitted when null), codeType=Int32 (enum HHNMLNJHFNG), extraDataId=Int64 (omitted when null). Host = | bare JSON string — the generated action-link code | +| GET | `actionlink/{code}` | none (code concatenated into path). Host = Link (11) | {CreatorPlayerId:Int32, Data:String, IsValid:Boolean} — tri-casing accepted | +| POST | `actionlink/{code}/consume` | path: actionlink/{WebUtility.UrlEncode(code)}/consume; form fields: id=String(code), validHours=Int32 (omitted when null), codeType=Int32, newPlayer=Boolean, newInstall=Boolean. Ho | {CreatorPlayerId:Int32, Data:String, IsValid:Boolean} | +| POST | `announcements/club/{clubId}` | form fields: title=String, body=String, imageName=String, meta=String (MetaData serialized to a JSON string). Host = Clubs (13). Client validates title/body length first (JBIDNCFHA | bare JSON integer (Int64) — the new AnnouncementId | +| GET | `announcements/club/{clubId} (GET)` | none. Host = Clubs (13) | {ClubId:Int64, LastReadAnnouncementId:Int64, Announcements:[{AnnouncementId:Int64, ClubId:Int64, CreatorAccountId:Int32, Title:String, Body:String, ImageName:String, Meta:String (raw JSON string; parsed client-side by JD | +| PUT | `announcements/club/{clubId}/{announcementId}` | form fields: announcementId=Int64, title=String, body=String, imageName=String. Host = Clubs (13) | none consumed; 2xx status only (LDGADANDBIO/void task) | +| DELETE | `announcements/club/{clubId}/{announcementId} (DELETE)` | none. Host = Clubs (13) | none consumed; 2xx status only | +| POST | `announcements/club/{clubId}/{announcementId}/read` | none (empty body). Host = Clubs (13) | none consumed; 2xx status only | +| GET | `announcements/mine` | none. Host = Clubs (13) | JSON array of announcement objects (same JDPPAFLFNBD shape as club board: AnnouncementId, ClubId, CreatorAccountId, Title, Body, ImageName, Meta, CreatedAt) | +| GET | `announcements/subscription/mine` | none. Host = Clubs (13) | JSON array of JDPPAFLFNBD announcement objects | +| GET | `announcements/v2/mine/unread (and announcements/v2/subscription/mine/unread, no query)` | none. Host = Clubs (13) | same wire shape as the sendAnnouncements variant: array of {ClubId, LastAnnouncementId, LastReadAnnouncementId, Announcements} — client only needs ClubIds here; Announcements may be empty arrays | +| GET | `announcements/v2/mine/unread?sendAnnouncements=true (and announcements/v2/subscription/mine/unread?sendAnnouncements=true)` | none beyond the literal query string. Host = Clubs (13) | JSON array of {ClubId:Int64, LastAnnouncementId:Int64, LastReadAnnouncementId:Int64, Announcements:[JDPPAFLFNBD objects]} — tri-casing accepted | +| GET | `api/announcement/v1/get` | none. Host = API (1) | JSON array of {AnnouncementId:Int64, AnnouncementType:Int32 (enum OHPHHEMBICB), Title:String, Body:String, ImageName:String, LinkType:Int32 (enum FNPKEEDHKFA), LinkName:String, LinkUri:String, LinkButtonLabel:String, Pla | +| GET | `api/config/v1/amplitude` | none | {AmplitudeKey:String, RudderStackKey:String, UseRudderStack:Boolean, StatSigKey:String} — tri-casing accepted | +| GET | `api/config/v1/azurespeech` | none | {Enabled:Boolean, Key:String, Region:String} — tri-casing accepted. NOTE: client key is "Key", NOT "SubscriptionKey" | +| GET | `api/config/v1/backtrace` | query params: platformType=String (from platform enum PAPLNIPKAMG via OLEJHIBGHEB), allocate=Boolean | {ANRThresholdMs, CaptureNativeCrashes, FilterType, LogLineCount, MessageCount, ReportBudget: Int32s; SampleRate:Single; MessageRegex:String, VersionRegex:String} — tri-casing accepted. Exact key↔field pairing among the s | +| GET | `api/config/v1/cohortnux/{cohortId}` | none (cohort id in path, Int32) | JSON array of {ButtonNumber:Int32, Version:Int32 (enum MDPAPEEJFED), Override:Int32 (enum IAEGEKLOMHF), CustomTitle:String, CustomDescription:String, CustomRoomName:String, DefaultTitle:String, DefaultDescription:String, | +| GET | `api/config/v1/freegiftbutton` | none | bare JSON boolean: true\|false | +| GET | `api/config/v2` | none | Object with exactly 8 recognized keys (each accepted in PascalCase/camelCase/lowercase): LevelProgressionMaps: [{Level:Int32, RequiredXp:Int32, GiftDropId:Int32?}], DailyObjectives: OIMDBINAEGE[][] i.e. array-of-arrays o | +| GET | `api/gameconfigs/v1/all` | none | JSON array of {Key:String, Value:String, StartTime:DateTime? (ISO8601), EndTime:DateTime?} — tri-casing accepted | +| GET | `api/testcasemanagement/v1/testcase/{id}` | none (string id concatenated into path) | {Id:String, Key:String, Title:String, Description:String, RoomName:String, Status:Int32 (enum LIHDCCJNKGC), AssignedPlayerNames:[String], Tags:[String], JiraUrl:String, Comments:[{Comment:String, CreatedAt:DateTime}]} — | +| POST | `api/testcasemanagement/v1/testcase/{id}/claim` | none (empty body) | none consumed (BNDIAONDFFF.KDOPJCNKOOK = fire, ignore body); 2xx status only | +| POST | `api/testcasemanagement/v1/testcase/{id}/comment` | raw JSON body = JsonConvert.SerializeObject(comment) → a JSON string literal, e.g. "\"my comment\"" | none consumed; 2xx status only | +| POST | `api/testcasemanagement/v1/testcase/{id}/status` | raw JSON body = the enum's integer as text (Int32.ToString → BNDIAONDFFF.FJLLPHFOOJJ), e.g. body "2" | none consumed; 2xx status only | +| POST | `api/testcasemanagement/v1/testcase/{id}/unclaim` | none (empty body) | none consumed; 2xx status only | +| GET | `api/testcasemanagement/v1/testpass/{id}` | none (UInt32 id in path) | single test-pass object, same shape as testpasssummary entry | +| GET | `api/testcasemanagement/v1/testpasssummary` | none | JSON array of {Id:UInt32, Name:String, Description:String, StartDate:DateTime, EndDate:DateTime?, WasManuallyClosed:Boolean, TestCases:[testcase objects, see testcase/{id}], Tags:[String], NumTestCases:Int32, NumPassedTe | +| GET | `config/categories` | none. Host = GJDLNNLKDIJ.PlatformNotifications (15) — DorkNet serves it at platformnotifications host / config/categories | JSON array of {CategoryId:Int32 (enum CCOKJMJOIJF), Importance:Int32 (enum ODEOILNDKIO), Name:String, Description:String, IsMuteable:Boolean} — tri-casing accepted | +| PUT | `datalink` | form field: data=String. Host = Link (11) | bare JSON string — the generated datalink code | +| GET | `datalink/{code}` | none (code in path). Host = Link (11) | {Data:String} — tri-casing accepted | +| GET | `emojiConfig/whitelistedEmojis` | none. Host = GJDLNNLKDIJ.Accounts (10) | bare JSON array of strings (emoji), e.g. ["😀","😁",...] | +| POST | `feed/query` | JSON body: {Take:Int32, ContinuationToken:String?, InjectedFeedItems:[{FeedItemType:Int32 (enum DAAEHNDDCCH), FeedItemId:Int64}], FeedInstanceId:String?}. Host = GJDLNNLKDIJ.Discov | {Success:Boolean, Error:String?, Value:{Items:[{FeedItemType:Int32 (enum DAAEHNDDCCH), FeedItemId:Int64, FeedAlgorithmVersion:Int16}], FeedInstanceId:String, FeedContext:String, FeedAlgorithmVersion:Int16}} — tri-casing | +| POST | `pageview/consume` | none (empty body). Host = GJDLNNLKDIJ.Link (11) | {Url:String, FreshnessSeconds:Double} — accepted as Url/url and FreshnessSeconds/freshnessSeconds/freshnessseconds. Empty Url = no deep-link | +| GET | `settings/partyinvite` | none. Host = GJDLNNLKDIJ.Chat (8) | {InviteLinkLifetimeInMinutes:Int32} — tri-casing accepted | + +#### Not HTTP routes + +These literals look like paths but are cache keys, deeplinks or MIME types: + +- `actionlink/` — Prefix constant +- `api/config/` — Prefix constant only +- `api/testcasemanagement/` — Prefix constant only +- `datalink/` — Prefix constant +- `feed/{0}/{1}` — External share (copy link / OS share sheet) of a feed image + +#### Defects + +##### `POST announcements/club/{clubId}` — MISSING (breaks-gameplay) + +Creating a club announcement POSTs to the COLLECTION URL announcements/club/{clubId} with form fields title/body/imageName/meta and expects a bare Int64 (the new AnnouncementId). DorkNet registers no POST at that template — and worse, the POST it does register at announcements/club/{clubId}/{announcementId} (ClubsController.cs:761) is bound to AnnouncementDelete. Create attempts 404 (route template requires two ids), so posting an announcement from the club UI always fails. + +Handler: `none (nearest: DorkNet.Server\Controllers\Clubs\ClubsController.cs:761 registers POST only at .../{announcementId})` + +**Fix.** Add [HttpPost("/announcements/club/{clubId:long}")] handler in ClubsController: authorize, check owner/moderator perms, read form fields title/body/imageName/meta (meta is a JSON string — store verbatim), insert ClubAnnouncementEntity, return Ok(newId) as a bare Int64. Remove the HttpPost binding from AnnouncementDelete. + +##### `PUT announcements/club/{clubId}/{announcementId}` — SHAPE_MISMATCH (breaks-gameplay) + +The PUT verb is registered — but it is bound to AnnouncementDelete: [HttpDelete]/[HttpPost]/[HttpPut] all decorate the same handler that calls clubs.DeleteAnnouncementAsync. The 2023 client PUTs form fields announcementId/title/body/imageName to EDIT an announcement (IKMMOCKDKAF.GHFAFMKHLCN); on DorkNet that request silently DELETES the announcement and returns 200, so the client believes the edit succeeded while the record is destroyed. Destructive data loss on a normal user action. + +Handler: `DorkNet.Server\Controllers\Clubs\ClubsController.cs:762-768` + +**Fix.** In ClubsController split the bindings: keep [HttpDelete] on AnnouncementDelete; add a dedicated [HttpPut("/announcements/club/{clubId:long}/{announcementId:long}")] edit handler that reads form fields announcementId/title/body/imageName, permission-checks, updates the ClubAnnouncementEntity, and returns 200 with empty body (client consumes nothing). Move the POST binding to the mark-read/create semantics it actually needs (client never POSTs this URL, so simply dropping it is also fine). + +##### `GET api/config/v2` — SHAPE_MISMATCH (degraded) + +Handler exists (GET api/config + api/config/v2) and returns RecRoomConfig, but the model (DorkNet.Models\Config\RecRoomConfig.cs) has no StorefrontConfig, RoomKeyConfig, or RoomCurrencyConfig properties, and LevelProgressionEntry has no GiftDropId. The 2023 reader (CDEDNEPPPND) registers exactly these keys; missing keys default, so the client's StorefrontConfig/RoomKeyConfig/RoomCurrencyConfig getters return null objects and every level's GiftDropId is null — currency-award cooldown, room-key cap, and room-currency gifting gate all run on nulls/zero (consumer NRE risk on the null nested objects is UNKNOWN but this exact pattern crashed boot for AutoMicMutingConfig). Present keys are fine: ShareBaseUrl, ServerMaintenance, AutoMicMutingConfig (8 floats), LevelProgressionMaps Level/RequiredXp, DailyObjectives (lowercase type/score accepted by tri-casing formatter DKMBKPLABAG). Extra 2020 keys (MessageOfTheDay, PhotonConfig, etc.) are ignored by the 2023 reader — harmless. + +Handler: `DorkNet.Server\Controllers\API\Config\V2\ConfigController.cs:12` + +**Fix.** In DorkNet.Models\Config\RecRoomConfig.cs add StorefrontConfig {AwardCurrencyCooldownSeconds:float}, RoomKeyConfig {MaxKeysPerRoom:int}, RoomCurrencyConfig {MinPlayerLevelForGifting:int} properties (JsonPropertyName PascalCase) with sane defaults, and add nullable int GiftDropId to LevelProgressionEntry. + +##### `GET config/categories` — SHAPE_MISMATCH (degraded) + +Server wraps the categories in a paged envelope {Results:[...],TotalResults:N}. The 2023 client deserializes a BARE array — method signature is FGLDKEJLAKB> (verified at LELAJKMOMIA.txt:1552); its generated List formatter expects a JSON array, gets an object, and the promise fails — the platform-notification category list in the settings UI never loads. Item keys themselves (CategoryId/Importance/Name/Description/IsMuteable) are correct per JNFDPPCIPHG.txt:371-446. + +Handler: `DorkNet.Server\Controllers\PlatformNotifications\PlatformNotificationsController.cs:138-159` + +**Fix.** In PlatformNotificationsController.Categories() return Ok(categories) (the bare array) instead of the {Results,TotalResults} wrapper on this march-2023 branch (both route aliases /config/categories and /platformnotifications/config/categories). + +##### `GET settings/partyinvite` — SHAPE_MISMATCH (degraded) + +Server returns a BARE integer (Content(value.ToString())) per a 2020-era comment claiming the contract is a bare Int32. The 2023 client deserializes an OBJECT: DICKGMODLGI with key InviteLinkLifetimeInMinutes (formatter AIEIPLDGFCF registers InviteLinkLifetimeInMinutes/inviteLinkLifetimeInMinutes/invitelinklifetimeinminutes — verified at AIEIPLDGFCF.txt:151-170). A JSON number where an object is expected fails the generated reader; DLDKCILCKNA's cached getter never resolves and party/chat invite-link creation from the watch runs with a default (0-minute) lifetime or fails outright. + +Handler: `DorkNet.Server\Controllers\PlayerSettings\PlayerSettingsController.cs:52-59` + +**Fix.** In PlayerSettingsController.GetPartyInvite return Ok(new { InviteLinkLifetimeInMinutes = }) on this branch. The stored bare-int setting row can stay; only the wire shape changes. (Client never POSTs this route, so SetPartyInvite is unused but harmless.) + +##### `POST feed/query` — MISSING (degraded) + +No handler anywhere in DorkNet.Server registers feed/query (verified server-routes.json + repo-wide grep; the only 'feed' routes are GET feed = api/photos/v1/feed alias in PhotosController.cs:96, api/site/v1/feed, discover/v2/feed — all unrelated). The 2023 home-screen personalized feed (KGLCPCEGCFF.PJGDPOLDBCJ, Discovery host) POSTs a JSON body {Take, ContinuationToken, InjectedFeedItems, FeedInstanceId} and expects {Success, Error, Value:{Items:[{FeedItemType,FeedItemId,FeedAlgorithmVersion}], FeedInstanceId, FeedContext, FeedAlgorithmVersion}}. A 404 rejects the promise and the home-tab feed surface shows empty/error content. + +**Fix.** Add POST /feed/query (new controller or DiscoveryController): parse the request body, return {Success:true, Error:(string?)null, Value:{Items:[...], FeedInstanceId:, FeedContext:"", FeedAlgorithmVersion:1}} with Items sourced from rooms/images the server wants to surface (FeedItemType per enum DAAEHNDDCCH, FeedItemId Int64); honor Take and return an empty Items list at end of pagination. + +##### `GET announcements/club/{clubId} (GET)` — SHAPE_MISMATCH (degraded) + +Server returns GroupByClub(rows) — a JSON ARRAY of per-club envelopes (List, ClubsController.cs:937-953). The 2023 client deserializes a SINGLE object FIAKMDGGIHH {ClubId, LastReadAnnouncementId, Announcements:[...]} (sig FGLDKEJLAKB, IKMMOCKDKAF.txt:1170). Array where object is expected fails the reader — the club announcement board never renders. Secondary defects in the envelope: LastReadAnnouncementId is serialized as explicit JSON null into a non-nullable client Int64 (throw-vs-default behavior of the generated reader on explicit null is UNKNOWN), and when the club has zero announcements GroupByClub returns [] instead of an object at all. Inner announcement objects (ToWireAnnouncement, :876-886) match JDPPAFLFNBD (AnnouncementId/ClubId/CreatorAccountId/Title/Body/ImageName/Meta-as-string/CreatedAt) — those are fine. + +Handler: `DorkNet.Server\Controllers\Clubs\ClubsController.cs:737-742` + +**Fix.** Make AnnouncementsForClub return one object: Ok(new { ClubId = clubId, LastReadAnnouncementId = , Announcements = rows.OrderByDescending(CreatedAt).Select(ToWireAnnouncement) }) — always an object, never an array, LastReadAnnouncementId always a number. + +##### `GET announcements/mine` — FIXED + +`ClubsController.AnnouncementsMine` → `ClubService.AnnouncementsForMemberClubsAsync`. Flat `List` of `ToWireAnnouncement` rows, newest first — no per-club envelope and no unread filter (the issuing method is `FGLDKEJLAKB> IOCFHCFLIMJ()` at IKMMOCKDKAF.txt:996, described in-binary as "get all announcements for clubs I'm in"). Membership rows carrying the pending (128) or ban (256) marker are excluded so a pending/banned account never sees a club's board, and disbanded clubs are re-filtered on State. + +##### `GET announcements/subscription/mine` — FIXED + +`ClubsController.AnnouncementsSubscriptionMine` → `ClubService.AnnouncementsForSubscribedClubsAsync`: same flat shape (`IIOMOLFOLPD` at IKMMOCKDKAF.txt:1083, route literal :1162), scoped to `ClubSubscriptions` rather than memberships. + +##### `GET announcements/v2/mine/unread (+ announcements/v2/subscription/mine/unread, with and without ?sendAnnouncements=true)` — SHAPE_MISMATCH (degraded) + +Routes and verb exist and Announcements payloads are always embedded (so ?sendAnnouncements=true is satisfied). Two row-shape defects vs client DTO NCHLBFPHFJE {ClubId, LastAnnouncementId, LastReadAnnouncementId, Announcements} (keys verified CAKCKAMAAPP.txt:331-406): (1) GroupByClub omits LastAnnouncementId entirely (defaults to 0 — tolerable for current client call sites, which only project ClubIds or read Announcements); (2) LastReadAnnouncementId is serialized as explicit JSON null while the client field is a non-nullable Int64 — whether the generated reader throws on explicit null (killing the whole unread poll) or defaults is UNKNOWN; do not ship a null. + +Handler: `DorkNet.Server\Controllers\Clubs\ClubsController.cs:151-167 (rows built at 937-953)` + +**Fix.** In ClubsController.GroupByClub emit LastAnnouncementId = ordered.First().Id and LastReadAnnouncementId = (a number, never null). Key casing is already fine (tri-cased reader). + +##### `GET actionlink/{code}` — FIXED + +Was unrouted (PageviewController.cs:28-32 documented actionlink/* as out-of-scope). Now `DorkNet.Server\Controllers\Link\LinkController.cs` — `[HttpGet("/actionlink/{code}")]`, `[AllowAnonymous]` because `ActionCode.ModifyPreAuthLaunchTarget` resolves an inbound deep link during BootSequence, before the player holds a token. Verb from CKBKHENHCAN.txt:793 (`045 Move rdx, 0`), host 11 at :798. Response is the camelCase anonymous object `{creatorPlayerId, data, isValid}`, which the tri-cased generated formatter DPIECEMDKPL accepts (:267/:278/:286 CreatorPlayerId, :294/:302 Data, :310/:318/:326 IsValid). Unknown/expired codes answer 200 with `IsValid:false` (never 404) so the promise resolves. Reading a code does not spend a use. + +##### `POST actionlink` — FIXED + +Now `LinkController.ActionLinkCreate` — `[HttpPost("/actionlink")]`, `[Authorize]`. Verb from CKBKHENHCAN.txt:1226 (`106 Move rdx, 2`), host `105 Move r8, 11` at :1225. Form fields bound through `FormOrJsonModelBinder`: data (:1234), validHours (:1249), maxCount (:1261), codeType (:1273), extraDataId (:1287). Return type is `FGLDKEJLAKB` (:860) so the body is a bare JSON string — emitted with `Content(JsonSerializer.Serialize(code), "application/json")`, because `Ok(string)` goes out as unquoted text/plain via StringOutputFormatter. + +Codes are 8 characters from the unambiguous alphabet `23456789ABCDEFGHJKLMNPQRSTUVWXYZ` (no O/0/I/1 — players re-type these into ActionCodeConsumptionModel), reserved with a global uniqueness check before insert. + +##### `POST actionlink/{code}/consume` — FIXED + +Now `LinkController.ActionLinkConsume` — `[HttpPost("/actionlink/{code}/consume")]`, `[AllowAnonymous]` (can fire from the pre-auth launch-target path). Verb from CKBKHENHCAN.txt:1675 (`077 Move rdx, 2`); path built by `String.Concat("actionlink/", WebUtility.UrlEncode(code), "/consume")` at :1665-1669. Form fields: id (:1684), validHours (:1692), codeType (:1720), newPlayer (:1732), newInstall (:1745). + +Semantics established from the call sites: `validHours` is the creating ActionCode's `Configuration.AutoRenewHours`, loaded immediately before dispatch (`043 Call Configuration.get_AutoRenewHours`, RoomCode_NestedType_IBLIEDEHMPI.txt:168; ClubCode_NestedType_EAGCMJOEANN.txt:78) and null when AutoRenew is off — so a present value renews the code's expiry instead of letting it age out. `codeType` is verified against the stored type (CBKKGBIJHAL: Unknown=-1, Friend=0, Referral=1, Meetup=2, Club=3, PlayerEvent=4, Room=5, Influencer=6, Photo=7; RoomCode passes 5 at IBLIEDEHMPI.txt:175, ClubCode passes 3 at EAGCMJOEANN.txt:85) so a room payload cannot be handed to the club handler. Expiry and maxCount are enforced; a successful redeem increments the use counter. + +Referral codes are the only type with no other server touchpoint — ReferralCode has no HTTP routes of its own and `IncentivizedReferralsController` counts `referral:credited:{inviterId}:{inviteeId}` rows that nothing wrote. Consuming a Referral code as a signed-in `newPlayer` now writes that row (on the invitee, matching the `externalinvite:redeemed:*` convention), so incentivized-referral progress is no longer permanently zero. + +##### `GET datalink/{code}` — FIXED + +Now `DorkNet.Server\Controllers\Link\LinkController.cs` — `[HttpGet("/datalink/{code}")]`, `[AllowAnonymous]` (resolved from a cold-boot deep link). Verb from CKBKHENHCAN.txt:352 (`045 Move rdx, 0`), literal `"datalink/"` at :345. Response is `{data}`; the generated formatter IKBPJACLIHH registers a single member Data (:139) plus the camelCase alias (:150). An unknown code returns an empty Data rather than 404 so the strict reader still gets an object. + +##### `PUT datalink` — FIXED + +Now `LinkController.DataLinkCreate` — `[HttpPut("/datalink")]`, `[Authorize]`. Verb from CKBKHENHCAN.txt:560 (`033 Move rdx, 3` = PUT), literal `"datalink"` at :558, sole form field `data` at :569, bound through `FormOrJsonModelBinder`. Return type `FGLDKEJLAKB` (:419) — bare JSON string, emitted with `Content(JsonSerializer.Serialize(code), "application/json")`. Codes here are 10 characters (nobody types a datalink code; it only travels inside a URL). + +##### Link-host storage note + +All five link routes persist in `PlayerSettings` — the server's existing general-purpose per-player key/value table already used for ban-appeal codes and external friend invites — under `actionlink:{CODE}` / `datalink:{CODE}` keys owned by the creator's row, so codes survive restarts without a new table. Action-link rows pack `{d:data, e:expiry, m:maxCount, u:uses, t:codeType, x:extraDataId}` into the single Value column with one-character keys; because `PlayerSettingEntity.Value` is `[MaxLength(1024)]` (a real `varchar(1024)` on Postgres), both create routes reject over-long payloads with a logged 400 instead of blowing up at SaveChanges. + +##### `POST api/testcasemanagement/v1/testcase/{id}/status` — SHAPE_MISMATCH (degraded) + +Route+verb exist, but the server binds [FromBody] StatusUpdateRequest {NewStatus:int} while the client sends a RAW JSON body that is just the enum integer as text (Int32.ToString → FJLLPHFOOJJ, e.g. body '2'). System.Text.Json cannot bind a bare number to an object, so model binding fails and ASP.NET returns 400 — setting pass/fail status from the QA tooling never persists. (Dev-only surface, hence degraded not breaks-gameplay.) + +Handler: `DorkNet.Server\Controllers\API\TestCaseManagement\TestCaseManagementController.cs:131-142` + +**Fix.** Change UpdateStatus to accept the bare int: either [FromBody] int newStatus, or read Request.Body as text and int.Parse it (the latter also tolerates a missing JSON content-type). Keep the 0-3 range check. + +##### `POST api/testcasemanagement/v1/testcase/{id}/comment` — MISSING (degraded) + +No /comment route — adding a comment from the QA tooling 404s. Related shape gap on GET testcase/{id}: the client DTO JOKHLOPJBLN also reads Comments:[{Comment:String, CreatedAt:DateTime}], and ToWireCase (TestCaseManagementController.cs:160-174) never emits a Comments key, so even stored comments would never render. + +Handler: `none (controller: TestCaseManagementController.cs registers claim/unclaim/status only)` + +**Fix.** In TestCaseManagementController add [HttpPost("api/testcasemanagement/v1/testcase/{id}/comment")] reading the raw body as a JSON string literal (client sends JsonConvert.SerializeObject(comment), e.g. "\"text\"") and persisting {Comment, CreatedAt}; add Comments = [...] to ToWireCase. + +##### `GET api/config/v1/azurespeech` — SHAPE_MISMATCH (cosmetic) + +Server sends SubscriptionKey (both casings) but the 2023 DTO LAKPIECKPOO reads the key literal "Key" (verified COIELAHKALG.txt:262-270 — Key/key registered, no SubscriptionKey). Client's Key stays null. Harmless today because the server also sends Enabled:false, which the client does read, so speech-to-text stays off. + +Handler: `DorkNet.Server\Controllers\API\Config\V1\ConfigController.cs:80-102` + +**Fix.** Add ["Key"] = string.Empty (alongside the existing keys) to the azurespeech dictionary in ConfigController.GetAzureSpeech; populate it with a real key if Azure speech is ever enabled. + +##### `GET api/config/v1/amplitude` — SHAPE_MISMATCH (cosmetic) + +Server sends only {AmplitudeKey:""}. The 2023 DTO FGJMMNJGDKB also reads RudderStackKey, UseRudderStack, StatSigKey (verified NNAGOPNGJFE.txt:343-418); all three default (null/false). Since AmplitudeKey is empty anyway, analytics is inert — no user-visible impact on a private server. + +Handler: `DorkNet.Server\Controllers\API\Config\V1\ConfigController.cs:21-22` + +**Fix.** Extend GetAmplitude to Ok(new { AmplitudeKey = "", RudderStackKey = "", UseRudderStack = false, StatSigKey = "" }) for exact-shape completeness. + +##### `GET api/config/v1/backtrace` — SHAPE_MISMATCH (cosmetic) + +Route, verb, and both query params (platformType, allocate — [FromQuery]) match. Of the 2023 DTO FDEGPICBIHO's key set {ANRThresholdMs, CaptureNativeCrashes, FilterType, LogLineCount, MessageCount, MessageRegex, ReportBudget, SampleRate, VersionRegex} the server sends only SampleRate and ReportBudget (both 0); the other seven default. With SampleRate=0 crash reporting is disabled by design, so defaults are benign. The extra 2020-era keys the server sends are ignored. + +Handler: `DorkNet.Server\Controllers\API\Config\V1\ConfigController.cs:31-78` + +**Fix.** Add the seven missing keys with disable-safe values (ANRThresholdMs:0, CaptureNativeCrashes:false, FilterType:0, LogLineCount:0, MessageCount:0, MessageRegex:"", VersionRegex:"") to the GetBacktrace response dictionary. + +##### `GET api/config/v1/cohortnux/{cohortId}` — SHAPE_MISMATCH (cosmetic) + +Handler returns four hardcoded (but functional) button configs with ButtonNumber/Version/Override/CustomTitle/CustomRoomName/DefaultTitle/DefaultRoomName. The 2023 DTO KNLHJKBMDIM additionally reads CustomDescription and DefaultDescription, which the server omits (default null) — NUX buttons render without description text. Types and the enum ints match; anonymous objects serialize PascalCase (global PropertyNamingPolicy=null, ServiceCollectionExtensions.cs:380-381) and the reader is tri-cased anyway. + +Handler: `DorkNet.Server\Controllers\API\Config\V1\ConfigController.cs:168-195` + +**Fix.** Add CustomDescription = "" and DefaultDescription = "" to each of the four entries in GetCohortNux. + +##### `GET api/announcement/v1/get` — SHAPE_MISMATCH (cosmetic) + +Handler exists and sends AnnouncementId/AnnouncementType/Title/Body/ImageName/LinkType/LinkName/LinkUri/Platform/CreatedAt (real data from CommunityBoardService). The 2023 DTO MMEIGLPHBMD additionally reads LinkButtonLabel (verified AKDFPANIAFL.txt:910) — absent, defaults null, so link buttons on announcement banners show no custom label. All other keys/types match; 2023 reader tolerates missing keys (no 2020-style required-key throw). + +Handler: `DorkNet.Server\Controllers\API\Announcements\AnnouncementsController.cs:21-44` + +**Fix.** Add LinkButtonLabel = "" (or an admin-configurable value) to the anonymous object in AnnouncementsController.Current. + +##### `GET api/testcasemanagement/v1/testcase/{id}` — SHAPE_MISMATCH (cosmetic) + +All keys present except Comments (client DTO JOKHLOPJBLN reads Comments:[{Comment,CreatedAt}]); defaults to null client-side — comments panel empty (pairs with the missing POST /comment route reported above). Extra keys (MinNumAssignedPlayers, AssignedPlayerIds, JiraBugUrl) are ignored. + +Handler: `DorkNet.Server\Controllers\API\TestCaseManagement\TestCaseManagementController.cs:71-77, 160-174` + +**Fix.** Add Comments = .Select(c => new { c.Comment, c.CreatedAt }) to ToWireCase once comment storage exists (see /comment finding). + +## Residual literals not matched to a server route + +Path-shaped literals in the client that no DorkNet route matches. Most are not +HTTP routes at all — the per-subsystem tables above classify each one. + +- `accounts/{0}/receives/{1}` +- `actionlink/` +- `announcements/mine` +- `announcements/subscription/mine` +- `club_events/{0}` +- `comments/create/{0}` +- `comments/delete/{0}` +- `comments/get/{0}` +- `comments/read/{0}/{1}` +- `datalink/` +- `event/{0}/instances` +- `event/{0}/{1}` +- `event_responses/{0}` +- `events_by_club_ids/` +- `friend/` +- `hot_rooms/{0}&skip={1}&take={2}` +- `image/{0}` +- `inventionsbycreators/{0}/{1}/{2}` +- `leaderboard/CheckAndSetStat` +- `magic_door/{0}` +- `meetup/` +- `my_visited_rooms/skip={0}&take={1}` +- `ownedby/{0}` +- `platformid/{0}` +- `playlistinteraction/{0}` +- `remote-run/push-to-studio` +- `remote_player_event_list/{0}` +- `role/{0}/{1}` +- `room_events/{0}` +- `roominteraction/{0}` +- `roomsbycreators/{0}&skip={1}&take={2}` +- `search/` +- `search_live/` +- `search_rooms/{0}&skip={1}&take={2}` +- `showcase/{0}` +- `store/invention/{0}` +- `store/item/{0}` +- `subscription/{0}` +- `unity_assets/{0}/{1}/{2}` +- `video/` +- `visited_rooms/account_id={0}&skip={1}&take={2}` + +## Keeping this accurate + +`DorkNet.Server.Tests/EndpointContractDiscovery.cs` enumerates the server route +table by reflection and is the ground truth for what the server serves — prefer +it over grepping for route attributes, which misses `[Route]`-only actions such +as the CDN `unity_assets` handler and mis-handles primary-constructor +controllers. + +When adding an endpoint, verify all three of: the route, the verb ordinal, and +whether the response is a bare scalar, an array, or a paged +`{Results, TotalResults}` container. Those three account for nearly every defect +catalogued above. diff --git a/tools/extract-client-dto-keys.py b/tools/extract-client-dto-keys.py new file mode 100644 index 0000000..8a5070e --- /dev/null +++ b/tools/extract-client-dto-keys.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Extract every generated JSON reader's key set from the 2023-03-21 ISIL dump. + +Why this exists +--------------- +When one of our responses carries the wrong keys the client does NOT report a +bad request. It answers HTTP 200, the client's generated reader throws while +deserialising, and the failure surfaces much later as a bare "Failed to copy +room" with no URL attached. Neither side logs the payload, so the only way to +know what the client actually wants is to read the readers out of the binary. + +Cpp2IL emits each generated reader as a `.ctor` that registers its property +names as string literals, three spellings per field: + + 262 Move rcx, "DataBlob" + 273 Move rcx, "dataBlob" + 281 Move rcx, "datablob" + +The PascalCase spelling is the wire name; the other two are the reader's +case-insensitive aliases. A run of >= MIN_FIELDS such triples is a DTO, and the +registration order is the field order. + +Output is a JSON catalogue: {typeName: [field, ...]} in registration order, for +the parity test to assert our responses against. + + python tools/extract-client-dto-keys.py \ + C:/tmp/recroom-2023-03-21-isil/IsilDump \ + DorkNet.Server.Tests/data/client-dto-keys-2023.json +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +# Two is deliberate, not lax. Several real wire DTOs are exactly that small — +# a room tag is {Tag, Type} — and a higher floor hides them, which is how an +# invented third key on the tag object went unnoticed. The alias rule in +# fields_in() is what rejects false positives, not the field count. +MIN_FIELDS = 2 + +LITERAL = re.compile(r'Move \w+, "([A-Za-z][A-Za-z0-9_]*)"') + + +def fields_in(text: str) -> list[str]: + """Ordered PascalCase field names, de-duplicated. + + A field is only counted when its lowercase alias also appears, which is what + separates a reader's property registrations from incidental string literals + (log messages, enum names) that happen to be capitalised. + """ + literals = LITERAL.findall(text) + seen_lower = {lit.lower() for lit in literals} + + fields: list[str] = [] + taken: set[str] = set() + for lit in literals: + if not lit[0].isupper(): + continue + key = lit.lower() + if key in taken: + continue + # The alias spellings are what mark this as a registered property. + if key not in seen_lower or literals.count(lit) == 0: + continue + camel = lit[0].lower() + lit[1:] + if camel not in literals and key not in literals: + continue + taken.add(key) + fields.append(lit) + return fields + + +def main() -> int: + if len(sys.argv) != 3: + print(__doc__) + return 2 + + dump_root, out_path = Path(sys.argv[1]), Path(sys.argv[2]) + if not dump_root.is_dir(): + print(f"not a directory: {dump_root}", file=sys.stderr) + return 1 + + catalogue: dict[str, list[str]] = {} + for path in sorted(dump_root.rglob("*.txt")): + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + if '"' not in text: + continue + fields = fields_in(text) + if len(fields) >= MIN_FIELDS: + catalogue[path.stem] = fields + + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(catalogue, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"{len(catalogue)} DTO reader(s) -> {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())