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