From 1a946f30235b05693ef6c03f9ea1039b7a4602f0 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:08:10 +0300 Subject: [PATCH] feat: Add AvatarItemId, TagList, and IsBaseAvatarItem to owned avatar items; implement stable ID generation and corresponding tests --- DorkNet.Server.Tests/OwnedAvatarItemsTests.cs | 106 ++++++++++++++++++ .../API/Avatar/V4/AvatarItemsController.cs | 48 ++++++++ 2 files changed, 154 insertions(+) create mode 100644 DorkNet.Server.Tests/OwnedAvatarItemsTests.cs diff --git a/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs b/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs new file mode 100644 index 0000000..0ecdd2c --- /dev/null +++ b/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs @@ -0,0 +1,106 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace DorkNet.Server.Tests; + +/// +/// api/avatar/v4/items — the owned/unlocked wardrobe. The 2023 client's +/// owned-item DTO (RecNet IFNGBPJGINL) requires AvatarItemId (Int32), +/// TagList (string) and IsBaseAvatarItem (bool). Omitting them +/// left every item at AvatarItemId 0; the client keys the wardrobe by that +/// id, so the second item collided, the unlocked-items list failed to build +/// (empty wardrobe = "no owned items"), and any refetch — including the one +/// after a purchase — NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew +/// ("can't buy items"). Each item must carry a unique, non-zero id. +/// +public sealed class OwnedAvatarItemsTests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public OwnedAvatarItemsTests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Owned_items_carry_unique_nonzero_ids_and_2023_keys() + { + using var setup = ApiClient(); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + using var client = ApiClient(player); + + var items = await GetJsonAsync(client, "/api/avatar/v4/items"); + Assert.True(items.GetArrayLength() > 0, "starter wardrobe was empty"); + + var ids = new List(); + foreach (var it in items.EnumerateArray()) + { + // Every 2023 key present and correctly typed. + Assert.Equal(JsonValueKind.Number, it.GetProperty("AvatarItemId").ValueKind); + Assert.Equal(JsonValueKind.String, it.GetProperty("TagList").ValueKind); + var isBase = it.GetProperty("IsBaseAvatarItem").ValueKind; + Assert.True(isBase is JsonValueKind.True or JsonValueKind.False); + Assert.Equal(JsonValueKind.String, it.GetProperty("AvatarItemDesc").ValueKind); + + var id = it.GetProperty("AvatarItemId").GetInt32(); + Assert.NotEqual(0, id); + ids.Add(id); + } + + // No duplicate ids — a collision is what crashed the wardrobe. + Assert.Equal(ids.Count, ids.Distinct().Count()); + } + + [Fact] + public async Task Purchased_item_appears_owned_with_valid_shape() + { + using var setup = ApiClient(); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + using var client = ApiClient(player); + + var before = (await GetJsonAsync(client, "/api/avatar/v4/items")).GetArrayLength(); + + // Buy the first avatar-backed watch-storefront item. + var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3"); + var purchasableId = storefront.GetProperty("StoreItems").EnumerateArray() + .First(r => !string.IsNullOrEmpty(r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString())) + .GetProperty("PurchasableItemId").GetInt32(); + + using var buy = await client.PostAsync("/api/storefronts/v2/buyItem", + new StringContent(JsonSerializer.Serialize(new + { + StorefrontType = 3, + PurchasableItemId = purchasableId, + CurrencyType = 2, + RequestedPrice = 1, + Gift = (object?)null, + }), Encoding.UTF8, "application/json")); + buy.EnsureSuccessStatusCode(); + + var after = await GetJsonAsync(client, "/api/avatar/v4/items"); + Assert.True(after.GetArrayLength() >= before, "owned count shrank after purchase"); + // Still no duplicate ids after the new item joins the list. + var ids = after.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList(); + Assert.DoesNotContain(0, ids); + Assert.Equal(ids.Count, ids.Distinct().Count()); + } + + private HttpClient ApiClient(GameClientSession? session = null) + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); + if (session is not null) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); + return client; + } + + private static async Task GetJsonAsync(HttpClient client, string path) + { + using var response = await client.GetAsync(path); + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return document.RootElement.Clone(); + } +} diff --git a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs index 17c1bbc..47f15db 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs @@ -173,10 +173,13 @@ public class AvatarItemsController( { AvatarItemType = item.AvatarItemType, AvatarItemDesc = ToWatchDesc(entry.FullDesc), + AvatarItemId = item.AvatarItemId, PlatformMask = item.PlatformMask, FriendlyName = item.FriendlyName, Tooltip = item.Tooltip, Rarity = item.Rarity, + TagList = item.TagList, + IsBaseAvatarItem = item.IsBaseAvatarItem, }); if (result.Count >= maxItems) break; } @@ -190,10 +193,13 @@ public class AvatarItemsController( { AvatarItemType = AvatarItemType.HairDye, AvatarItemDesc = hairColorGuid, + AvatarItemId = StableAvatarItemId(hairColorGuid), PlatformMask = unchecked((int)0xFFFFFFFF), FriendlyName = friendlyName, Tooltip = string.Empty, Rarity = 0, + TagList = string.Empty, + IsBaseAvatarItem = false, }); } return Ok(result); @@ -223,10 +229,13 @@ public class AvatarItemsController( { AvatarItemType = AvatarItemType.HairDye, AvatarItemDesc = hairColorGuid, + AvatarItemId = StableAvatarItemId(hairColorGuid), PlatformMask = unchecked((int)0xFFFFFFFF), FriendlyName = friendlyName, Tooltip = string.Empty, Rarity = 0, + TagList = string.Empty, + IsBaseAvatarItem = false, }); } return result; @@ -506,10 +515,13 @@ public class AvatarItemsController( // GUID as a color GUID and log "Can not find hair dye color". AvatarItemType = AvatarItemType.Outfit, AvatarItemDesc = row.Guid + ",,,", + AvatarItemId = StableAvatarItemId(row.Guid), PlatformMask = unchecked((int)0xFFFFFFFF), FriendlyName = pretty, Tooltip = string.Empty, Rarity = 0, + TagList = string.Empty, + IsBaseAvatarItem = false, }; if (!bySlot.TryGetValue(row.OutfitType, out var lst)) bySlot[row.OutfitType] = lst = new List(); @@ -520,6 +532,21 @@ public class AvatarItemsController( return new CatalogData(items, safe, bySlot, slotByGuid); } + /// Stable, deterministic, non-zero 31-bit id for an owned + /// avatar item, derived from its base GUID (FNV-1a). The 2023 client + /// keys the wardrobe by AvatarItemId, so it must be unique per + /// item and stable across restarts. Colored variants reuse their base + /// item's id (a player owns the base OR a variant, never both in the + /// list), so no collision. + public static int StableAvatarItemId(string? avatarItemDescOrGuid) + { + var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0]; + uint h = 2166136261u; + foreach (var c in guid) h = (h ^ c) * 16777619u; + var id = (int)(h & 0x7FFFFFFF); + return id == 0 ? 1 : id; + } + private static string PrettyName(string raw) { if (string.IsNullOrEmpty(raw)) return string.Empty; @@ -618,6 +645,16 @@ public class UnlockedAvatarItemDto [JsonPropertyName("AvatarItemDesc")] public string AvatarItemDesc { get; set; } = string.Empty; + /// The 2023 client's owned-item DTO (RecNet IFNGBPJGINL) reads + /// AvatarItemId as an Int32. Omitting it left every owned + /// item at id 0; the client keys its wardrobe by AvatarItemId, so a + /// second item collided and the whole unlocked-items list failed to + /// build — the wardrobe showed nothing owned and buying (which refetches + /// this list) NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew. Emit a + /// stable, unique, non-zero id. + [JsonPropertyName("AvatarItemId")] + public int AvatarItemId { get; set; } + [JsonPropertyName("PlatformMask")] public int PlatformMask { get; set; } @@ -629,4 +666,15 @@ public class UnlockedAvatarItemDto [JsonPropertyName("Rarity")] public int Rarity { get; set; } + + /// CSV tag string (IFNGBPJGINL.TagList). Empty is fine — + /// AvatarItemMetadata.ParseTagList tolerates it — but the key must be + /// present and a string, never null. + [JsonPropertyName("TagList")] + public string TagList { get; set; } = string.Empty; + + /// Whether this is a default base-body item vs a cosmetic + /// (IFNGBPJGINL.IsBaseAvatarItem). False for wardrobe/catalog items. + [JsonPropertyName("IsBaseAvatarItem")] + public bool IsBaseAvatarItem { get; set; } }