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.
This commit is contained in:
Alexa
2026-10-15 21:44:49 +03:00
parent 975753deac
commit ee7d614262
4 changed files with 102 additions and 6 deletions
@@ -215,6 +215,48 @@ 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);
}
}
// Beard_Close: one of the client's defaultUnlockedVariants. // Beard_Close: one of the client's defaultUnlockedVariants.
private const string DefaultItemGuid = "8d10cc78-6b00-45f3-affb-205e9cc5b03f"; 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 // reverts to the real per-player wardrobe below. Bounded by the same
// maxItems ceiling so a future watch build can't be over-fed. // maxItems ceiling so a future watch build can't be over-fed.
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync()) if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
return Ok(BuildEntireCatalogOwned(maxItems)); return await WithStoreInfoAsync(BuildEntireCatalogOwned(maxItems));
var pid = this.CurrentPlayerId(); 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 var avatar = await db.Avatars
.Where(a => a.PlayerId == me) .Where(a => a.PlayerId == me)
@@ -134,7 +134,7 @@ public class AvatarItemsController(
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _)) .Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(); .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 catalog = _catalog.Value;
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections); var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
@@ -226,7 +226,34 @@ public class AvatarItemsController(
IsBaseAvatarItem = false, 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 /// <summary>The free items every player owns
@@ -379,8 +406,8 @@ public class AvatarItemsController(
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null) .Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
.Where(item => item is not null) .Where(item => item is not null)
.Select(item => item!) .Select(item => item!)
.ToArray(); .ToList();
return Ok(rows); return Ok(await WithStoreInfoAsync(rows));
} }
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot. /// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
+26
View File
@@ -1191,6 +1191,32 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
/// id-lists (toptoday) resolve against the gift-drop cache.</summary> /// id-lists (toptoday) resolve against the gift-drop cache.</summary>
public static int PurchasableItemIdFor(StoreItemEntity i) => (int)(i.Id & 0x7fffffff); 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: /// <summary>GiftManager.GiftRarity wire values:
/// None=-1, Common=0, Uncommon=10, Rare=20, Epic=30, Legendary=50.</summary> /// None=-1, Common=0, Uncommon=10, Rare=20, Epic=30, Legendary=50.</summary>
private static int MapRarity(string? category) => category?.ToLowerInvariant() switch private static int MapRarity(string? category) => category?.ToLowerInvariant() switch
+1
View File
@@ -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/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` (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` (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 | | `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 | | `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 | | `GET api/ugcPurchasables/v1/items/room/{roomId}` | 404 — only the ?roomId= form existed | path form added |