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; }
}