From 6ee1d6f9b325afc1c06ba4fb0a6a6eabb9335fbc Mon Sep 17 00:00:00 2001
From: Alexa <53471801+Alexa-RR@users.noreply.github.com>
Date: Thu, 15 Oct 2026 14:08:11 +0300
Subject: [PATCH] fix: presence, profile edits, outfit menu and RecNetResult
writes for the 2023 client
Every change below was checked against the 2023-03-21 client disassembly;
file:line evidence is in the code comments and in
docs/recroom-2023-client-api-complete.md.
Presence
- matchmake/none means "online, in no room" (its only caller is
TryGoToOfflineRoom, which ignores the reply). The server used to keep the
old room or invent a dorm, so the next heartbeat looked out of sync and
the client cancelled its load to chase that room.
- The join watchdog no longer clears presence 30 s into a long room load;
it is a 3 minute backstop that drops the room only.
- One fan-out (PresenceBroadcastService) for every transition: deferred
joins, matchmake/none, logout, failed joins, visibility changes, SignalR
connect/disconnect. isOnline is sent on its own; pushes never go to the
subject, because a self push moves the player.
- Online-status visibility (Public / Friends / Favorites / Appear offline)
is stored and honoured by GET /player and by the live pushes.
Profile
- PUT account/me/{emoji,personalpronouns,identityflags,bannerimage} were
404; added with the client's real form keys and persisted (new Players
columns plus migration). Every account read now returns them, and edits
push AccountUpdate to everyone.
Outfit menu
- Saved outfits kept OutfitSelectionsV2 and CustomAvatarItems, so loading a
slot restores what was saved instead of a stale outfit.
- isCreationAllowedForAccount answers the {Success, Error} envelope the
client parses; minPriceForPublicItem is a bare int; gifts/generate accepts
form bodies; lockeditems returns the full owned-item shape; colourways no
longer share their base item's AvatarItemId.
RecNetResult writes
- About 40 writes look status-only but parse the body as {Success, Error}.
An empty 200 showed "Failed to ..." after the server applied the change,
and a body without Success threw "Error message cannot be null". Clubs,
announcements, keepsakes, comments/read, subscriptions, purchase offers
and privacy settings now answer RecNetResult.
- Login token and device-code grants are real; remoteauth approves the
device code; createlogintoken returns a bare string.
- New: incentivizedreferrals/referrals, account/bulk/{email,phone},
GET strings, account/me/confirmphone, PlayMenuCarouselOrdering_1.
Route inventory and coverage test extended to match.
README now describes the 2023 branch.
---
DorkNet.Models/Auth/RecNetResult.cs | 37 +
.../IncentivizedReferralsTests.cs | 80 +
DorkNet.Server.Tests/OutfitMenu2023Tests.cs | 222 +
.../PresenceTransitionTests.cs | 289 ++
DorkNet.Server.Tests/ProfileEdit2023Tests.cs | 257 ++
.../RecNetResultEnvelopeTests.cs | 278 ++
.../data/client-routes-2023.tsv | 42 +
.../API/Account/AccountApiController.cs | 29 +-
.../API/Avatar/V2/AvatarGiftsController.cs | 20 +-
.../Avatar/V3/AvatarSavedOutfitsController.cs | 121 +-
.../API/Avatar/V4/AvatarItemsController.cs | 27 +-
.../API/Comments/CommentsController.cs | 9 +-
.../API/Config/V1/ConfigController.cs | 9 +-
.../CustomAvatarItemsController.cs | 44 +-
.../IncentivizedReferralsController.cs | 52 +
.../API/Keepsakes/KeepsakesController.cs | 8 +-
.../PlayerEvents/PlayerEventsController.cs | 42 +-
.../PlayerWarningsController.cs | 18 +-
.../RoomCurrenciesController.cs | 6 +-
.../Subscriptions/SubscriptionsController.cs | 5 +-
.../Accounts/AccountsController.cs | 268 +-
.../Controllers/Auth/AuthController.cs | 73 +-
.../Controllers/Clubs/ClubsController.cs | 73 +-
.../Controllers/Match/GoToController.cs | 16 +-
.../Controllers/Match/MatchController.cs | 57 +-
.../Match/MatchPlayerController.cs | 42 +-
.../Match/PlayerPresenceController.cs | 117 +-
DorkNet.Server/Controllers/RecNetResults.cs | 36 +
.../Controllers/Strings/StringsController.cs | 17 +
DorkNet.Server/Data/Entities/PlayerEntity.cs | 23 +
DorkNet.Server/Data/url-coverage.md | 14 +-
DorkNet.Server/Hubs/NotifyHub.cs | 34 +-
...110121_PlayerProfile2023Fields.Designer.cs | 3673 +++++++++++++++++
.../20261015110121_PlayerProfile2023Fields.cs | 63 +
.../DorkNetDbContextModelSnapshot.cs | 19 +
DorkNet.Server/Services/AccountDtos.cs | 46 +
.../Services/DeviceAuthorizations.cs | 109 +
DorkNet.Server/Services/JoinTimeoutService.cs | 35 +-
.../Services/NotificationService.cs | 100 +-
.../Services/PlayerPresenceService.cs | 118 +-
.../Services/PresenceBroadcastService.cs | 188 +
DorkNet.Server/Startup/DatabaseBootstrap.cs | 23 +
.../Startup/ServiceCollectionExtensions.cs | 2 +
README.md | 231 +-
docs/recroom-2023-client-api-complete.md | 142 +-
45 files changed, 6666 insertions(+), 448 deletions(-)
create mode 100644 DorkNet.Server.Tests/IncentivizedReferralsTests.cs
create mode 100644 DorkNet.Server.Tests/OutfitMenu2023Tests.cs
create mode 100644 DorkNet.Server.Tests/PresenceTransitionTests.cs
create mode 100644 DorkNet.Server.Tests/ProfileEdit2023Tests.cs
create mode 100644 DorkNet.Server.Tests/RecNetResultEnvelopeTests.cs
create mode 100644 DorkNet.Server/Controllers/RecNetResults.cs
create mode 100644 DorkNet.Server/Migrations/20261015110121_PlayerProfile2023Fields.Designer.cs
create mode 100644 DorkNet.Server/Migrations/20261015110121_PlayerProfile2023Fields.cs
create mode 100644 DorkNet.Server/Services/AccountDtos.cs
create mode 100644 DorkNet.Server/Services/DeviceAuthorizations.cs
create mode 100644 DorkNet.Server/Services/PresenceBroadcastService.cs
diff --git a/DorkNet.Models/Auth/RecNetResult.cs b/DorkNet.Models/Auth/RecNetResult.cs
index b979a1d..1ac43da 100644
--- a/DorkNet.Models/Auth/RecNetResult.cs
+++ b/DorkNet.Models/Auth/RecNetResult.cs
@@ -88,6 +88,36 @@ public class RecNetAccount
// Missing → LitJson throws and 2020.12 surfaces "Malformed Response".
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ // 2023-03-21 account reader (RecNet.Runtime CICAKEDCJLA / MJKDDBKGNOP /
+ // AEGCKMKICFG) registers these keys in three casings each, e.g.
+ // "DisplayEmoji"/"displayEmoji"/"displayemoji" (CICAKEDCJLA.txt:1398-1550).
+ // Missing keys default on the client, which is why profile emoji,
+ // banner, pronouns and identity flags never rendered for anyone.
+ private string displayEmoji = string.Empty;
+ private string bannerImage = string.Empty;
+
+ [JsonPropertyName("displayEmoji")]
+ public string DisplayEmoji
+ {
+ get => displayEmoji;
+ set => displayEmoji = value ?? string.Empty;
+ }
+
+ [JsonPropertyName("bannerImage")]
+ public string BannerImage
+ {
+ get => bannerImage;
+ set => bannerImage = value ?? string.Empty;
+ }
+
+ /// Pronoun flags enum, serialized as its Int32 value.
+ [JsonPropertyName("personalPronouns")]
+ public int PersonalPronouns { get; set; }
+
+ /// Identity flags enum, serialized as its Int32 value.
+ [JsonPropertyName("identityFlags")]
+ public int IdentityFlags { get; set; }
}
///
@@ -112,6 +142,13 @@ public class RecNetSelfAccount : RecNetAccount
[JsonPropertyName("parentAccountId")]
public int? ParentAccountId { get; set; } = null;
+
+ // 2023 self-account reader key (CICAKEDCJLA.txt:1310). An absent key
+ // defaults to 0, which the settings UI renders as "no username changes
+ // left". DorkNet does not rate-limit username changes, so report a
+ // generous allowance.
+ [JsonPropertyName("availableUsernameChanges")]
+ public int AvailableUsernameChanges { get; set; } = 99;
}
///
diff --git a/DorkNet.Server.Tests/IncentivizedReferralsTests.cs b/DorkNet.Server.Tests/IncentivizedReferralsTests.cs
new file mode 100644
index 0000000..1358912
--- /dev/null
+++ b/DorkNet.Server.Tests/IncentivizedReferralsTests.cs
@@ -0,0 +1,80 @@
+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;
+
+///
+/// GET api/incentivizedreferrals/referrals?take=&continuationToken= is
+/// assembled by the client's URL builder (RecNet.Runtime/LINKIECPDML.txt:677-737),
+/// so it never showed up as a route literal and had no handler. The reply is the
+/// GPJIMAHMAIO page: {Referrals:[{InviteeAccountId, CreatedAt, VerifiedAt}],
+/// ContinuationToken} (KOEEACLMAFE.txt:203-238, ADAFNGNADFH.txt:279-346).
+///
+public sealed class IncentivizedReferralsTests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public IncentivizedReferralsTests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public async Task Referrals_page_lists_credited_invitees_with_a_continuation_token()
+ {
+ using var setup = Client();
+ var referrer = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var inviteeA = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ var inviteeB = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ // Same row LinkController.CreditReferralAsync writes on the invitee.
+ foreach (var invitee in new[] { inviteeA, inviteeB })
+ db.PlayerSettings.Add(new PlayerSettingEntity
+ {
+ PlayerId = invitee.PlayerId,
+ Key = $"referral:credited:{referrer.PlayerId}:{invitee.PlayerId}",
+ Value = DateTime.UtcNow.ToString("O"),
+ });
+ await db.SaveChangesAsync();
+ }
+
+ using var client = Client(referrer);
+ var first = JsonDocument.Parse(
+ await client.GetStringAsync("/api/incentivizedreferrals/referrals?take=1")).RootElement;
+ var firstPage = Get(first, "Referrals");
+ Assert.Equal(1, firstPage.GetArrayLength());
+ var token = Get(first, "ContinuationToken").GetString();
+ Assert.False(string.IsNullOrEmpty(token));
+
+ var second = JsonDocument.Parse(await client.GetStringAsync(
+ $"/api/incentivizedreferrals/referrals?take=1&continuationToken={token}")).RootElement;
+ var secondPage = Get(second, "Referrals");
+ Assert.Equal(1, secondPage.GetArrayLength());
+ Assert.Equal(JsonValueKind.Null, Get(second, "ContinuationToken").ValueKind);
+
+ var ids = new[] { firstPage[0], secondPage[0] }
+ .Select(e => Get(e, "InviteeAccountId").GetInt64())
+ .OrderBy(x => x)
+ .ToArray();
+ Assert.Equal(new[] { inviteeA.PlayerId, inviteeB.PlayerId }.OrderBy(x => x), ids);
+ // CreatedAt is a non-nullable DateTime on the client: it must be present.
+ Assert.Equal(JsonValueKind.String, Get(firstPage[0], "CreatedAt").ValueKind);
+ }
+
+ // The client formatter registers PascalCase, camelCase and lowercase.
+ private static JsonElement Get(JsonElement e, string pascal) =>
+ e.TryGetProperty(pascal, out var v) ? v : e.GetProperty(char.ToLowerInvariant(pascal[0]) + pascal[1..]);
+
+ private HttpClient Client(GameClientSession? session = null)
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
+ if (session is not null)
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return client;
+ }
+}
diff --git a/DorkNet.Server.Tests/OutfitMenu2023Tests.cs b/DorkNet.Server.Tests/OutfitMenu2023Tests.cs
new file mode 100644
index 0000000..76b404b
--- /dev/null
+++ b/DorkNet.Server.Tests/OutfitMenu2023Tests.cs
@@ -0,0 +1,222 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using DorkNet.Server.Controllers.API.Avatar.V4;
+using DorkNet.Server.Data;
+using DorkNet.Server.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// Wire contract of the 2023-03-21 client's outfit / wardrobe menu, as read
+/// from RecNet.Runtime ISIL: saved-outfit slots (BEPMJICDNDD via converter
+/// IMMOJHBENFI), the custom-avatar-item gates the menu queries when it opens,
+/// the locked-item lookup, the owned list's per-entry ids, and the gift
+/// generate call the menu's gift flow makes.
+///
+public sealed class OutfitMenu2023Tests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public OutfitMenu2023Tests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public async Task Creation_allowed_gate_is_a_success_error_envelope()
+ {
+ // MPBLNLMCEDL.GGMDLMLJDDK parses DJMHAFPGLLN {Success, Error}
+ // (LMCCNLLHLCJ.txt:191-254); a bare boolean is what logged
+ // "Failed to query if local account can create Custom Avatar Items".
+ using var setup = ApiClient();
+ var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ using var client = ApiClient(player);
+
+ var body = await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationAllowedForAccount");
+ Assert.Equal(JsonValueKind.Object, body.ValueKind);
+ Assert.Equal(JsonValueKind.True, body.GetProperty("Success").ValueKind);
+ Assert.Equal(JsonValueKind.Null, body.GetProperty("Error").ValueKind);
+ }
+
+ [Fact]
+ public async Task Sibling_gates_and_min_price_are_bare_scalars()
+ {
+ using var client = ApiClient();
+ Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isCreationEnabled")).ValueKind);
+ Assert.Equal(JsonValueKind.True, (await GetJsonAsync(client, "/api/customAvatarItems/v1/isRenderingEnabled")).ValueKind);
+
+ // b__69_0(Int32 minimumPrice) — a bare integer.
+ var min = await GetJsonAsync(client, "/api/customAvatarItems/v1/minPriceForPublicItem");
+ Assert.Equal(JsonValueKind.Number, min.ValueKind);
+ Assert.True(min.GetInt32() > 0);
+ }
+
+ [Fact]
+ public async Task Saved_outfit_round_trips_every_client_key()
+ {
+ using var setup = ApiClient();
+ var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+ using var client = ApiClient(player);
+
+ var shirt = Guid.NewGuid();
+ // Exactly what the client's writer (IMMOJHBENFI.txt:781-910) emits.
+ var outfit = new Dictionary
+ {
+ ["Slot"] = 3,
+ ["PreviewImageName"] = "preview-3.jpg",
+ ["OutfitSelections"] = "",
+ ["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"a\",\"b\"]}",
+ ["FaceFeatures"] = "{\"ver\":3}",
+ ["SkinColor"] = "skin-guid",
+ ["HairColor"] = "hair-guid",
+ ["CustomAvatarItems"] = new[] { new { CustomAvatarItemId = shirt, BodyPart = 2 } },
+ };
+ await PostJsonAsync(client, "/api/avatar/v3/saved/set", outfit);
+
+ var list = await GetJsonAsync(client, "/api/avatar/v3/saved");
+ var saved = list.EnumerateArray().Single(o => o.GetProperty("Slot").GetInt32() == 3);
+ Assert.Equal("preview-3.jpg", saved.GetProperty("PreviewImageName").GetString());
+ Assert.Equal("{\"v\":2,\"items\":[\"a\",\"b\"]}", saved.GetProperty("OutfitSelectionsV2").GetString());
+ Assert.Equal("{\"ver\":3}", saved.GetProperty("FaceFeatures").GetString());
+ Assert.Equal("skin-guid", saved.GetProperty("SkinColor").GetString());
+ Assert.Equal("hair-guid", saved.GetProperty("HairColor").GetString());
+ var items = saved.GetProperty("CustomAvatarItems");
+ Assert.Equal(1, items.GetArrayLength());
+ Assert.Equal(shirt, items[0].GetProperty("CustomAvatarItemId").GetGuid());
+ Assert.Equal(2, items[0].GetProperty("BodyPart").GetInt32());
+
+ // Overwriting the slot replaces it rather than adding a second row.
+ outfit["OutfitSelectionsV2"] = "{\"v\":2,\"items\":[\"c\"]}";
+ outfit["CustomAvatarItems"] = Array.Empty