From 7f507702b9bd1b460e46dd54feb93ff9aeb157ee Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:07:00 +0300 Subject: [PATCH] fix: owned items showed a price because they were served as base avatar items defaultbaseavataritems returned the first 100 catalog items. The 2023 client flags every row of that list as a custom-clothing template (AvatarItemData.IsBaseAvatarItem, keyed by AvatarItemId) and skips templates when it rebuilds the owned outfit-menu entries, so those 100 ordinary items (80sHipHopJacket, Beard Close, ...) showed a price even when owned and equipped, while the cart (which checks the owned set) said "owned". - defaultbaseavataritems returns only the UGC templates, UGCTee_Shirt (AvatarItemId 2184) and UGCPulloverHoodie_Shirt (2918), as the real service served them - those two keep the same ids in every list and carry IsBaseAvatarItem - they're no longer sold (existing shop rows are removed on seed) or listed as owned clothing --- DorkNet.Server.Tests/OwnedAvatarItemsTests.cs | 40 +++++++++++++++++++ .../API/Avatar/V4/AvatarItemsController.cs | 36 +++++++++++++---- DorkNet.Server/Services/StoreService.cs | 9 ++++- docs/recroom-2023-client-api-complete.md | 3 +- 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs b/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs index 4f8bf10..35f0fbc 100644 --- a/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs +++ b/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs @@ -257,6 +257,46 @@ public sealed class OwnedAvatarItemsTests : IClassFixture } } + [Fact] + public async Task Base_avatar_items_are_only_the_custom_clothing_templates() + { + // The client flags every defaultbaseavataritems row as a custom- + // clothing template and leaves it out of the owned rebuild, so a + // normal item listed here showed a price even when owned. + using var anonymous = ApiClient(); + var rows = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultbaseavataritems"); + var byDesc = rows.EnumerateArray().ToDictionary( + r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0], + r => r); + Assert.Equal(2, byDesc.Count); + Assert.Equal(2184, byDesc["c5d70cb4-71dd-4fe4-b719-34fe2073c611"].GetProperty("AvatarItemId").GetInt32()); + Assert.Equal(2918, byDesc["95a519de-f2cb-429c-b014-508477f20d42"].GetProperty("AvatarItemId").GetInt32()); + Assert.All(byDesc.Values, r => Assert.True(r.GetProperty("IsBaseAvatarItem").GetBoolean())); + + // Templates aren't sold or listed as owned clothing. + var shop = await GetJsonAsync(anonymous, "/api/storefronts/v3/giftdropstore/3"); + var shopDescs = shop.GetProperty("StoreItems").EnumerateArray() + .Select(r => r.GetProperty("GiftDrop").GetProperty("AvatarItemDesc").GetString() ?? ""); + Assert.DoesNotContain(shopDescs, d => byDesc.ContainsKey(d.Split(',')[0])); + + await using (var scope = _factory.Services.CreateAsyncScope()) + await scope.ServiceProvider.GetRequiredService() + .SetAllAvatarItemsOwnedEnabledAsync(true); + try + { + var owned = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked"); + var ownedDescs = owned.EnumerateArray().Select(r => r.GetProperty("AvatarItemDesc").GetString()!.Split(',')[0]).ToList(); + Assert.DoesNotContain(ownedDescs, byDesc.ContainsKey); + Assert.Contains("3c2ed30c-ca2a-4f9f-9bfa-ffe561c4a686", ownedDescs); // 80sHipHopJacket + } + 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 c25b1f9..2287803 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs @@ -167,6 +167,7 @@ public class AvatarItemsController( // miss and silently drop every owned colored item (the contest // hat "I bought it but it never shows in my wardrobe" bug). if (!catalog.Items.TryGetValue(entry.BaseGuid, out var item)) continue; + if (item.IsBaseAvatarItem) continue; // custom-clothing template, not clothing var slot = catalog.SlotByGuid.TryGetValue(entry.BaseGuid, out var value) ? value : -1; perSlot.TryGetValue(slot, out var count); @@ -277,6 +278,8 @@ public class AvatarItemsController( foreach (var item in catalog.Items.Values) { if (result.Count >= maxItems) return result; + // Custom-clothing templates aren't ownable clothing. + if (item.IsBaseAvatarItem) continue; result.Add(item); } // Colored variants aren't in the base avatar_item_lookup catalog, so @@ -326,14 +329,33 @@ public class AvatarItemsController( return result; } + /// The custom-clothing templates ("base avatar items") and + /// their RecNet AvatarItemIds, as the real service served them. The + /// client flags every row of defaultbaseavataritems as a base + /// item (AvatarItemData.IsBaseAvatarItem, keyed by AvatarItemId in + /// OutfitManager.AODPBDNAGII) and leaves base items out of the owned + /// rebuild (MBPOFMEHDIJ), so they never read as owned clothing. This + /// endpoint used to return the first 100 catalog items, which turned + /// ordinary items (80sHipHopJacket, Beard Close, ...) into templates + /// that showed a price even when owned and equipped. + public static readonly IReadOnlyDictionary BaseAvatarItemIds = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["c5d70cb4-71dd-4fe4-b719-34fe2073c611"] = 2184, // UGCTee_Shirt + ["95a519de-f2cb-429c-b014-508477f20d42"] = 2918, // UGCPulloverHoodie_Shirt + }; + + public static bool IsBaseAvatarItemGuid(string? guid) => + !string.IsNullOrEmpty(guid) && BaseAvatarItemIds.ContainsKey(guid); + [HttpGet("api/avatar/v1/defaultbaseavataritems")] [AllowAnonymous] public IActionResult DefaultBaseAvatarItems() { - var rows = _catalog.Value.Items.Values - .OrderBy(i => i.AvatarItemType) - .ThenBy(i => i.FriendlyName) - .Take(100) + var items = _catalog.Value.Items; + var rows = BaseAvatarItemIds.Keys + .Where(items.ContainsKey) + .Select(g => items[g]) .ToList(); return Ok(rows); } @@ -607,13 +629,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), + AvatarItemId = BaseAvatarItemIds.TryGetValue(row.Guid, out var baseId) ? baseId : StableAvatarItemId(row.Guid), PlatformMask = unchecked((int)0xFFFFFFFF), FriendlyName = pretty, Tooltip = string.Empty, Rarity = 0, TagList = string.Empty, - IsBaseAvatarItem = false, + IsBaseAvatarItem = IsBaseAvatarItemGuid(row.Guid), }; if (!bySlot.TryGetValue(row.OutfitType, out var lst)) bySlot[row.OutfitType] = lst = new List(); @@ -626,7 +648,7 @@ public class AvatarItemsController( var defaults = dump.DefaultUnlockedVariants .Where(v => string.IsNullOrEmpty(v.Swatch) && string.IsNullOrEmpty(v.Mask) && string.IsNullOrEmpty(v.Decal)) .Select(v => v.Item) - .Where(g => !string.IsNullOrEmpty(g) && items.ContainsKey(g)) + .Where(g => !string.IsNullOrEmpty(g) && items.ContainsKey(g) && !IsBaseAvatarItemGuid(g)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); diff --git a/DorkNet.Server/Services/StoreService.cs b/DorkNet.Server/Services/StoreService.cs index 5d0dc8e..d7cf2d6 100644 --- a/DorkNet.Server/Services/StoreService.cs +++ b/DorkNet.Server/Services/StoreService.cs @@ -179,7 +179,11 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio foreach (var row in stale) { var guid = row.Slug.Substring("wardrobe-".Length); - if (!safe.Contains(guid)) toDelete.Add(row.Id); + // Custom-clothing templates aren't sold; drop rows seeded + // before they were excluded. + if (!safe.Contains(guid) || + DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.IsBaseAvatarItemGuid(guid)) + toDelete.Add(row.Id); } if (toDelete.Count > 0) { @@ -690,6 +694,9 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio var added = false; foreach (var (guid, dto) in catalog) { + // The UGC templates are the base of custom clothing, not shop + // items (the real store never listed them). + if (dto.IsBaseAvatarItem) continue; var slug = $"wardrobe-{guid}"; if (existingSlugs.Contains(slug)) continue; existingSlugs.Add(slug); diff --git a/docs/recroom-2023-client-api-complete.md b/docs/recroom-2023-client-api-complete.md index e167095..0adf4ae 100644 --- a/docs/recroom-2023-client-api-complete.md +++ b/docs/recroom-2023-client-api-complete.md @@ -217,6 +217,7 @@ verified against the binary before the change, and the full test suite passes. | `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/avatar/v1/defaultbaseavataritems` | returned the first 100 catalog items; the client flags every row as a custom-clothing template (`AvatarItemData.IsBaseAvatarItem`, keyed by AvatarItemId) and skips templates in the owned rebuild (`OutfitManager.MBPOFMEHDIJ`), so those 100 ordinary items (80sHipHopJacket, Beard Close, ...) showed a price even when owned and equipped | only the UGC templates `UGCTee_Shirt` (AvatarItemId 2184) and `UGCPulloverHoodie_Shirt` (2918), `IsBaseAvatarItem: true`; they're no longer sold or listed as owned clothing | | `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 | @@ -1349,7 +1350,7 @@ Verified all 37 real client HTTP calls against the DorkNet server source (every | Verb | Route | Request | Response | |---|---|---|---| -| GET | `api/avatar/v1/defaultbaseavataritems` | none | JSON array of the same UnlockedAvatarItem shape as v4/items | +| GET | `api/avatar/v1/defaultbaseavataritems` | none | JSON array of the same UnlockedAvatarItem shape as v4/items: only the custom-clothing templates (UGCTee_Shirt 2184, UGCPulloverHoodie_Shirt 2918) with `IsBaseAvatarItem: true`. Every row becomes a template client-side, so never list ordinary clothing here | | GET | `api/avatar/v1/defaultunlocked` | none | Same array shape as v4/items (server aliases it to the same handler; logged out it returns the `default_unlocked_variants` items, logged in the full owned set including them) | | GET | `api/avatar/v1/lockeditems` | Query: repeated key "desc" carrying avatar-item desc strings (list added via helper 0x181F2E250 with key literal "desc") | JSON array of List — the same element as v4/items (CLIANCNHNIE reader: AvatarItemType, AvatarItemDesc, FriendlyName, Tooltip, Rarity, TagList, AvatarItemId, IsBaseAvatarItem) for each requested desc the caller does NOT own | | GET | `api/avatar/v2` | none | Object with keys (exact literals in reader EKHJKDNHOPB, case-tolerant Pascal/camel/lower): OutfitSelections(string), OutfitSelectionsV2(string), FaceFeatures(string), SkinColor(string), HairColor(string), CustomAvatarIte |