Compare commits

..

5 Commits

Author SHA1 Message Date
Alexa 2dbd03b9ae 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
2026-10-15 22:08:59 +03:00
Alexa f4b4836375 Merge pull request #57 from DorkSquadRR/fix/owned-item-metadata
Owned avatar items use their shop tile's name, tooltip and rarity
2026-10-15 21:45:40 +03:00
Alexa ee7d614262 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.
2026-10-15 21:44:49 +03:00
Alexa 93b4e43316 Merge pull request #56 from DorkSquadRR/feat/cv2-network-limits
Free default avatar items + DorkNet.Quest client patcher
2026-10-15 20:33:18 +03:00
Alexa 8373e592f4 Merge pull request #55 from DorkSquadRR/feat/cv2-network-limits
2023 client: presence, profile, outfits, RecNetResult writes, CircuitsV2 limits
2026-10-15 14:10:25 +03:00
4 changed files with 181 additions and 15 deletions
@@ -215,6 +215,88 @@ public sealed class OwnedAvatarItemsTests : IClassFixture<DorkNetServerFactory>
}
}
[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<DorkNet.Server.Services.ServerSettingsService>()
.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<string, JsonElement>(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<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(false);
}
}
[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<DorkNet.Server.Services.ServerSettingsService>()
.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<DorkNet.Server.Services.ServerSettingsService>()
.SetAllAvatarItemsOwnedEnabledAsync(false);
}
}
// Beard_Close: one of the client's defaultUnlockedVariants.
private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f";
@@ -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);
@@ -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);
@@ -226,7 +227,34 @@ public class AvatarItemsController(
IsBaseAvatarItem = false,
});
}
return Ok(result);
return await WithStoreInfoAsync(result);
}
/// <summary>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.</summary>
private async Task<List<UnlockedAvatarItemDto>> WithStoreInfoAsync(List<UnlockedAvatarItemDto> 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();
}
/// <summary>The free items every player owns
@@ -250,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
@@ -299,14 +329,33 @@ public class AvatarItemsController(
return result;
}
/// <summary>The custom-clothing templates ("base avatar items") and
/// their RecNet AvatarItemIds, as the real service served them. The
/// client flags every row of <c>defaultbaseavataritems</c> 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.</summary>
public static readonly IReadOnlyDictionary<string, int> BaseAvatarItemIds =
new Dictionary<string, int>(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);
}
@@ -379,8 +428,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));
}
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
@@ -580,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<string>();
@@ -599,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();
+34 -1
View File
@@ -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);
@@ -1191,6 +1198,32 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
/// id-lists (toptoday) resolve against the gift-drop cache.</summary>
public static int PurchasableItemIdFor(StoreItemEntity i) => (int)(i.Id & 0x7fffffff);
/// <summary>Shop metadata for an avatar item, keyed by the exact
/// AvatarItemDesc a shop tile carries.</summary>
public sealed record AvatarItemStoreInfo(string FriendlyName, string Tooltip, int Rarity);
/// <summary>FriendlyName / Tooltip / Rarity of every avatar-item and
/// hair-dye shop tile, keyed by the same AvatarItemDesc
/// <see cref="BuildGiftDrop"/> 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.</summary>
public static async Task<Dictionary<string, AvatarItemStoreInfo>> 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<string, AvatarItemStoreInfo>(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;
}
/// <summary>GiftManager.GiftRarity wire values:
/// None=-1, Common=0, Uncommon=10, Rare=20, Epic=30, Legendary=50.</summary>
private static int MapRarity(string? category) => category?.ToLowerInvariant() switch
+3 -1
View File
@@ -216,6 +216,8 @@ 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/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 |
@@ -1348,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<IFNGBPJGINL> — 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 |