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 |