From ee7d61426203bac4661861eae22c9e6a9b74aeef Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:44:49 +0300 Subject: [PATCH] fix: owned avatar items use their shop tile's name, tooltip and rarity The owned list (v4/items, defaultunlocked, lockeditems) took names from the catalog dump, so colour variants read as asset names like "(Shirt_Winter)", every rarity was 0 and every tooltip empty. 1,864 of the 2,719 items in both lists disagreed with their shop tile. Each owned row now takes FriendlyName, Tooltip and Rarity from the store row with the same AvatarItemDesc (exact match; colour-variant combination ids are case-sensitive). Rows with no shop tile keep the catalog name. Rarity uses the same GiftRarity enum on both DTOs. --- DorkNet.Server.Tests/OwnedAvatarItemsTests.cs | 42 +++++++++++++++++++ .../API/Avatar/V4/AvatarItemsController.cs | 39 ++++++++++++++--- DorkNet.Server/Services/StoreService.cs | 26 ++++++++++++ docs/recroom-2023-client-api-complete.md | 1 + 4 files changed, 102 insertions(+), 6 deletions(-) diff --git a/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs b/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs index 0464d7c..4f8bf10 100644 --- a/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs +++ b/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs @@ -215,6 +215,48 @@ public sealed class OwnedAvatarItemsTests : IClassFixture } } + [Fact] + public async Task Owned_items_carry_the_same_name_tooltip_and_rarity_as_their_shop_tile() + { + using var setup = ApiClient(); + var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain); + + await using (var scope = _factory.Services.CreateAsyncScope()) + await scope.ServiceProvider.GetRequiredService() + .SetAllAvatarItemsOwnedEnabledAsync(true); + try + { + using var client = ApiClient(player); + var owned = await GetJsonAsync(client, "/api/avatar/v4/items"); + var shop = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3"); + + var tiles = new Dictionary(StringComparer.Ordinal); + foreach (var row in shop.GetProperty("StoreItems").EnumerateArray()) + { + var drop = row.GetProperty("GiftDrop"); + var desc = drop.GetProperty("AvatarItemDesc").GetString(); + if (!string.IsNullOrEmpty(desc)) tiles.TryAdd(desc, drop); + } + + var matched = 0; + foreach (var item in owned.EnumerateArray()) + { + if (!tiles.TryGetValue(item.GetProperty("AvatarItemDesc").GetString()!, out var tile)) continue; + matched++; + Assert.Equal(tile.GetProperty("FriendlyName").GetString(), item.GetProperty("FriendlyName").GetString()); + Assert.Equal(tile.GetProperty("Tooltip").GetString(), item.GetProperty("Tooltip").GetString()); + Assert.Equal(tile.GetProperty("Rarity").GetInt32(), item.GetProperty("Rarity").GetInt32()); + } + Assert.True(matched > 0, "no owned item matched a shop tile"); + } + finally + { + await using var scope = _factory.Services.CreateAsyncScope(); + await scope.ServiceProvider.GetRequiredService() + .SetAllAvatarItemsOwnedEnabledAsync(false); + } + } + // Beard_Close: one of the client's defaultUnlockedVariants. private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f"; diff --git a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs index 404b78f..c25b1f9 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs @@ -115,10 +115,10 @@ public class AvatarItemsController( // reverts to the real per-player wardrobe below. Bounded by the same // maxItems ceiling so a future watch build can't be over-fed. if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync()) - return Ok(BuildEntireCatalogOwned(maxItems)); + return await WithStoreInfoAsync(BuildEntireCatalogOwned(maxItems)); var pid = this.CurrentPlayerId(); - if (pid is not long me) return Ok(BuildDefaultUnlocked(maxItems)); + if (pid is not long me) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems)); var avatar = await db.Avatars .Where(a => a.PlayerId == me) @@ -134,7 +134,7 @@ public class AvatarItemsController( .Where(slug => StoreService.TryGetHairDyePayload(slug, out _)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); - if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(BuildDefaultUnlocked(maxItems)); + if (owned.Count == 0 && ownedHairDyes.Count == 0) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems)); var catalog = _catalog.Value; var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections); @@ -226,7 +226,34 @@ public class AvatarItemsController( IsBaseAvatarItem = false, }); } - return Ok(result); + return await WithStoreInfoAsync(result); + } + + /// Give each owned row the same FriendlyName, Tooltip and + /// Rarity its shop tile shows. The client keeps this list's metadata + /// per item, and the catalog dump only carries asset names (e.g. + /// "(Shirt_Winter)" for colour variants) with no rarity or tooltip, so + /// the owned list disagreed with the shop for most items. Rows with no + /// shop tile keep the catalog name. Catalog DTOs are shared, so matched + /// rows are copied, never mutated. + private async Task> WithStoreInfoAsync(List rows) + { + var info = await StoreService.LoadAvatarItemStoreInfoAsync(db); + if (info.Count == 0) return rows; + return rows.Select(r => info.TryGetValue(r.AvatarItemDesc, out var s) + ? new UnlockedAvatarItemDto + { + AvatarItemType = r.AvatarItemType, + AvatarItemDesc = r.AvatarItemDesc, + AvatarItemId = r.AvatarItemId, + PlatformMask = r.PlatformMask, + FriendlyName = string.IsNullOrWhiteSpace(s.FriendlyName) ? r.FriendlyName : s.FriendlyName, + Tooltip = s.Tooltip, + Rarity = s.Rarity, + TagList = r.TagList, + IsBaseAvatarItem = r.IsBaseAvatarItem, + } + : r).ToList(); } /// The free items every player owns @@ -379,8 +406,8 @@ public class AvatarItemsController( .Select(guid => catalog.TryGetValue(guid, out var item) ? item : null) .Where(item => item is not null) .Select(item => item!) - .ToArray(); - return Ok(rows); + .ToList(); + return Ok(await WithStoreInfoAsync(rows)); } /// Per-OutfitType slot index → list of GUIDs in that slot. diff --git a/DorkNet.Server/Services/StoreService.cs b/DorkNet.Server/Services/StoreService.cs index 0c3c738..5d0dc8e 100644 --- a/DorkNet.Server/Services/StoreService.cs +++ b/DorkNet.Server/Services/StoreService.cs @@ -1191,6 +1191,32 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio /// id-lists (toptoday) resolve against the gift-drop cache. public static int PurchasableItemIdFor(StoreItemEntity i) => (int)(i.Id & 0x7fffffff); + /// Shop metadata for an avatar item, keyed by the exact + /// AvatarItemDesc a shop tile carries. + public sealed record AvatarItemStoreInfo(string FriendlyName, string Tooltip, int Rarity); + + /// FriendlyName / Tooltip / Rarity of every avatar-item and + /// hair-dye shop tile, keyed by the same AvatarItemDesc + /// sends (ordinal: colour-variant + /// combination ids are case-sensitive). The owned-items list uses it so + /// an item reads the same in the wardrobe as in the shop. + public static async Task> LoadAvatarItemStoreInfoAsync(DorkNetDbContext db) + { + var rows = await db.StoreItems.AsNoTracking() + .Select(i => new { i.Slug, i.DisplayName, i.Description, i.Category }) + .ToListAsync(); + var info = new Dictionary(StringComparer.Ordinal); + foreach (var row in rows) + { + string desc; + if (TryGetHairDyePayload(row.Slug, out _, out var hairColorGuid)) desc = hairColorGuid; + else if (!TryGetAvatarItemPayload(row.Slug, out _, out desc)) continue; + if (string.IsNullOrEmpty(desc)) continue; + info.TryAdd(desc, new AvatarItemStoreInfo(row.DisplayName ?? string.Empty, row.Description ?? string.Empty, MapRarity(row.Category))); + } + return info; + } + /// GiftManager.GiftRarity wire values: /// None=-1, Common=0, Uncommon=10, Rare=20, Epic=30, Legendary=50. private static int MapRarity(string? category) => category?.ToLowerInvariant() switch diff --git a/docs/recroom-2023-client-api-complete.md b/docs/recroom-2023-client-api-complete.md index 6854ad2..e167095 100644 --- a/docs/recroom-2023-client-api-complete.md +++ b/docs/recroom-2023-client-api-complete.md @@ -216,6 +216,7 @@ verified against the binary before the change, and the full test suite passes. | `GET api/avatar/v1/lockeditems` | 5-key subset: AvatarItemId 0, no TagList/IsBaseAvatarItem | full IFNGBPJGINL owned-item shape | | `GET api/avatar/v4/items` (colored variants) | a colorway reused its base item's AvatarItemId, so owning a base + colorways collided in the id-keyed wardrobe | id hashed from the full descriptor | | `GET api/avatar/v4/items` / `defaultunlocked` / `lockeditems` (default items) | the client REPLACES its unlocked set with each downloaded list (OutfitManager.GFEIDFKKNMO clears field +0x70) and never merges its own defaults, so the free `default_unlocked_variants` items (e.g. Beard Close) showed a price | every list includes the 239 default items; `defaultunlocked` serves them before login; `lockeditems` never reports them | +| `GET api/avatar/v4/items` / `defaultunlocked` / `lockeditems` (metadata) | names came from the catalog dump (`(Shirt_Winter)` for colour variants), rarity was always 0 and tooltips empty, so 1,864 items disagreed with their shop tile | each owned row takes FriendlyName / Tooltip / Rarity from its shop tile (`StoreService.LoadAvatarItemStoreInfoAsync`), matched on the exact AvatarItemDesc | | `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 |