From cebe7ae3ee24111d590e9d956252574f7829b8e7 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:29:32 +0300 Subject: [PATCH] fix: free default avatar items showed a price in the 2023 outfit menu The 2023 client replaces its unlocked-item set with every list it downloads (OutfitManager clears and refills it) and never merges its own default list, so the server has to include the free default_unlocked_variants items. v4/items only returned the player's inventory, so items like Beard Close showed as buyable. - v4/items returns the player's items plus the 239 default items, deduped, with room kept for them under MaxUnlockedItems - defaultunlocked returns the default items before login - lockeditems never reports a default item as locked --- DorkNet.Server.Tests/OwnedAvatarItemsTests.cs | 36 ++++++++++ .../API/Avatar/V4/AvatarItemsController.cs | 71 +++++++++++++++++-- docs/recroom-2023-client-api-complete.md | 3 +- 3 files changed, 102 insertions(+), 8 deletions(-) diff --git a/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs b/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs index 6683526..0464d7c 100644 --- a/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs +++ b/DorkNet.Server.Tests/OwnedAvatarItemsTests.cs @@ -215,6 +215,42 @@ public sealed class OwnedAvatarItemsTests : IClassFixture } } + // Beard_Close: one of the client's defaultUnlockedVariants. + private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f"; + + [Fact] + public async Task Default_unlocked_items_are_owned_by_every_player() + { + // The client replaces its unlocked set with each list it downloads, + // so v4/items must carry the free default items or they show a price. + 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"); + var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList(); + Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase); + // A default item the starter inventory also holds appears once. + Assert.Equal(descs.Count, descs.Distinct(StringComparer.OrdinalIgnoreCase).Count()); + var ids = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemId").GetInt32()).ToList(); + Assert.Equal(ids.Count, ids.Distinct().Count()); + + var locked = await GetJsonAsync(client, $"/api/avatar/v1/lockeditems?avatarItemDescs={DefaultItemGuid},,,"); + Assert.Equal(0, locked.GetArrayLength()); + } + + [Fact] + public async Task Default_unlocked_endpoint_serves_defaults_before_login() + { + using var anonymous = ApiClient(); + var items = await GetJsonAsync(anonymous, "/api/avatar/v1/defaultunlocked"); + var descs = items.EnumerateArray().Select(x => x.GetProperty("AvatarItemDesc").GetString()!).ToList(); + Assert.Contains($"{DefaultItemGuid},,,", descs, StringComparer.OrdinalIgnoreCase); + Assert.Equal( + DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.DefaultUnlockedGuids.Count, + descs.Count); + } + private HttpClient ApiClient(GameClientSession? session = null) { var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); diff --git a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs index 5ee173e..404b78f 100644 --- a/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs +++ b/DorkNet.Server/Controllers/API/Avatar/V4/AvatarItemsController.cs @@ -59,7 +59,15 @@ namespace DorkNet.Server.Controllers.API.Avatar.V4; /// makes every store item appear owned. We instead intersect the /// caller's with the master /// catalog so the watch sees only items the player actually owns. -/// Anonymous calls return []. +/// +/// DEFAULT-UNLOCKED items: the 2023 client REPLACES its unlocked set with +/// each list it downloads (OutfitManager.GFEIDFKKNMO clears field +0x70, +/// then refills it; IsAvatarItemUnlocked is a HashSet lookup on that +/// field). It never merges in its own baked default list, so the server +/// must include the free starter items (avatar_item_lookup.json +/// default_unlocked_variants) in every response. Without them a +/// default item such as Beard Close showed a price in the outfit menu. +/// Anonymous calls (defaultunlocked before login) get the defaults. /// [ApiController] public class AvatarItemsController( @@ -110,7 +118,7 @@ public class AvatarItemsController( return Ok(BuildEntireCatalogOwned(maxItems)); var pid = this.CurrentPlayerId(); - if (pid is not long me) return Ok(new List()); + if (pid is not long me) return Ok(BuildDefaultUnlocked(maxItems)); var avatar = await db.Avatars .Where(a => a.PlayerId == me) @@ -126,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(new List()); + if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(BuildDefaultUnlocked(maxItems)); var catalog = _catalog.Value; var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections); @@ -142,7 +150,10 @@ public class AvatarItemsController( .ThenByDescending(x => x.OriginalIndex) .Select(x => x.Entry); - var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count); + // Room is kept for the default-unlocked items so a large wardrobe + // can't push them out (they'd show as buyable again). + var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count - catalog.DefaultUnlocked.Count); + var emittedBase = new HashSet(StringComparer.OrdinalIgnoreCase); var perSlot = new Dictionary(); var result = new List(Math.Min(owned.Count, maxItems)); foreach (var entry in ordered) @@ -162,6 +173,8 @@ public class AvatarItemsController( if (count >= maxPerSlot && !equipped.Contains(entry.BaseGuid)) continue; perSlot[slot] = count + 1; + if (string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase)) + emittedBase.Add(entry.BaseGuid); // Preserve the bought color: a bare entry (FullDesc == BaseGuid) // emits the catalog DTO as-is ("{guid},,,"); a colored entry // overrides AvatarItemDesc with the stored swatch/mask desc so @@ -189,6 +202,12 @@ public class AvatarItemsController( if (result.Count >= maxItems) break; } + foreach (var guid in catalog.DefaultUnlocked) + { + if (result.Count >= maxItems) break; + if (emittedBase.Add(guid)) result.Add(catalog.Items[guid]); + } + foreach (var slug in ownedHairDyes) { if (result.Count >= maxItems) break; @@ -210,6 +229,14 @@ public class AvatarItemsController( return Ok(result); } + /// The free items every player owns + /// (default_unlocked_variants), capped at . + private static List BuildDefaultUnlocked(int maxItems) + { + var catalog = _catalog.Value; + return catalog.DefaultUnlocked.Take(maxItems).Select(g => catalog.Items[g]).ToList(); + } + /// The full owned set when the "everyone owns all avatar /// items" toggle is on: every catalog item (already carrying the /// "{guid},,," desc) plus every permanent hair dye, capped at @@ -295,6 +322,10 @@ public class AvatarItemsController( /// data against the same safe list. public static IReadOnlySet SafeGuids => _catalog.Value.SafeGuids; + /// Base GUIDs of the free items every player owns + /// (default_unlocked_variants in avatar_item_lookup.json). + public static IReadOnlyList DefaultUnlockedGuids => _catalog.Value.DefaultUnlocked; + /// Master catalog of all renderable items, keyed by /// GUID. Used by callers that need item metadata (slot type, /// name, hair-color flags) outside of the per-player ownership @@ -332,6 +363,8 @@ public class AvatarItemsController( foreach (var guid in ParseInventory(invJson)) owned.Add(guid); } + // Free default items are owned by everyone, logged in or not. + owned.UnionWith(_catalog.Value.DefaultUnlocked); // The client parses this as List — the SAME element as // v4/items (FFFIMAGLKEG.txt:3222-3465, NBLIDNCEELM), read by @@ -485,7 +518,8 @@ public class AvatarItemsController( IReadOnlyDictionary Items, IReadOnlySet SafeGuids, IReadOnlyDictionary> BySlot, - IReadOnlyDictionary SlotByGuid); + IReadOnlyDictionary SlotByGuid, + IReadOnlyList DefaultUnlocked); private static CatalogData LoadCatalog() { @@ -512,7 +546,8 @@ public class AvatarItemsController( new Dictionary(), new HashSet(), new Dictionary>(), - new Dictionary()); + new Dictionary(), + Array.Empty()); using var fs = System.IO.File.OpenRead(path); var dump = JsonSerializer.Deserialize(fs, @@ -559,7 +594,16 @@ public class AvatarItemsController( slotByGuid[row.Guid] = row.OutfitType; } - return new CatalogData(items, safe, bySlot, slotByGuid); + // Default-unlocked entries are all plain base items (no swatch, mask + // or decal) in the March dump; only keep ones the catalog can render. + 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)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new CatalogData(items, safe, bySlot, slotByGuid, defaults); } /// Stable, deterministic, non-zero 31-bit id for an owned @@ -621,6 +665,19 @@ public class AvatarItemsController( public List SwatchGuids { get; set; } = new(); [JsonPropertyName("decal_guids")] public List DecalGuids { get; set; } = new(); + + // The client config's defaultUnlockedVariants: free items every + // player owns. + [JsonPropertyName("default_unlocked_variants")] + public List DefaultUnlockedVariants { get; set; } = new(); + } + + private sealed class LookupVariant + { + [JsonPropertyName("item")] public string Item { get; set; } = string.Empty; + [JsonPropertyName("mask")] public string Mask { get; set; } = string.Empty; + [JsonPropertyName("swatch")] public string Swatch { get; set; } = string.Empty; + [JsonPropertyName("decal")] public string Decal { get; set; } = string.Empty; } private sealed class LookupItem diff --git a/docs/recroom-2023-client-api-complete.md b/docs/recroom-2023-client-api-complete.md index 3f14cc2..6854ad2 100644 --- a/docs/recroom-2023-client-api-complete.md +++ b/docs/recroom-2023-client-api-complete.md @@ -215,6 +215,7 @@ verified against the binary before the change, and the full test suite passes. | `POST api/avatar/v{2,3}/gifts/generate` | 415 on the client's form-urlencoded body (seen live) | form-or-JSON binder | | `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/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 | @@ -1348,7 +1349,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/defaultunlocked` | none | Same array shape as v4/items (server aliases it to the same handler) | +| 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 | | GET | `api/avatar/v2/gifts` | none | JSON array of gift objects. Server wire (verified live, DorkNet AvatarGiftsController.ToWire): Id(Int64), FromPlayerId(Int64?), ConsumableItemDesc(string), AvatarItemType(Int32), AvatarItemDesc(string, 4-comma-part 'guid |