mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
Merge pull request #57 from DorkSquadRR/fix/owned-item-metadata
Owned avatar items use their shop tile's name, tooltip and rarity
This commit is contained in:
@@ -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.
|
||||
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);
|
||||
@@ -226,7 +226,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
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
|
||||
|
||||
@@ -1191,6 +1191,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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user