mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
7f507702b9
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
828 lines
40 KiB
C#
828 lines
40 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using DorkNet.Server.Auth;
|
|
using DorkNet.Server.Data;
|
|
using DorkNet.Server.Services;
|
|
|
|
namespace DorkNet.Server.Controllers.API.Avatar.V4;
|
|
|
|
/// <summary>
|
|
/// api.rec.net/api/avatar/v{2,3,4}/items — list of avatar items the
|
|
/// caller has UNLOCKED. The watch's
|
|
/// <c>RecNet.Avatars+<DowloadUnlockedAvatarItems></c> coroutine
|
|
/// (Cpp2IL_ISIL/.../Avatars_NestedType__DowloadUnlockedAvatarItems_d__21.txt:149
|
|
/// hits <c>"{api/avatar/}v4/items"</c>) parses the result as
|
|
/// <c>List<UnlockedAvatarItem></c>; the wardrobe UI then
|
|
/// renders a "buy" button for any item NOT in this list and an
|
|
/// "equip" affordance for items that are.
|
|
///
|
|
/// Wire shape per
|
|
/// Cpp2IL_ISIL/.../Avatars_NestedType_UnlockedAvatarItem.txt:186-212:
|
|
/// "AvatarItemType" (int — Outfit=0 / HairDye=1, NOT slot-based),
|
|
/// "AvatarItemDesc" (string — the item GUID with three empty
|
|
/// customizations appended, see below),
|
|
/// "PlatformMask" (int), "FriendlyName" (string),
|
|
/// "Tooltip" (string), "Rarity" (int).
|
|
///
|
|
/// IMPORTANT — <c>AvatarItemDesc</c> format: the client passes this
|
|
/// string to <c>AvatarItem.FromRecNetString(string)</c>
|
|
/// (Cpp2IL_ISIL/.../RecRoom/Avatar/Data/Runtime/AvatarItem.txt:81-257).
|
|
/// The desc is <c>String.Split(',')</c> into <c>guid + customization[0..3]</c>
|
|
/// — the inner overload then indexes <c>customization[0]</c>,
|
|
/// <c>customization[1]</c>, <c>customization[2]</c> and throws
|
|
/// <c>ArgumentException</c> at <c>ArraySegment.get_Current</c> if the
|
|
/// segment is shorter than 3. Every desc we ship MUST therefore have
|
|
/// at least 4 comma-separated parts, so we suffix bare GUIDs with
|
|
/// <c>",,,"</c> (three empty customizations).
|
|
///
|
|
/// CATALOG SOURCE — <c>data/avatar_item_lookup.json</c>. Generated by
|
|
/// the manual/CI-only BepInEx patch under
|
|
/// <c>tools/AvatarCatalogDumpPatch</c>. That patch reads the watch's bundled
|
|
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
|
/// SerializedDictionary at boot and writes every (guid, AvatarItemData)
|
|
/// pair to JSON. THIS is the authoritative safe list — any GUID NOT
|
|
/// in this file throws <c>InvalidOperationException("Missing data
|
|
/// for guid …")</c> inside the watch's wardrobe parser, which aborts
|
|
/// the entire coroutine on the first bad item and leaves the drawer
|
|
/// empty. Items extracted from <c>outfits_assets_*.bundle</c>
|
|
/// MonoBehaviours (the older <c>data/wardrobe_items.json</c>) are
|
|
/// MASK + SWATCH asset GUIDs, NOT AvatarItem GUIDs, so that file
|
|
/// can't be used as a safelist.
|
|
///
|
|
/// PER-PLAYER ownership: the watch intersects each store tile's
|
|
/// <c>AvatarItemDesc</c> against THIS list to flip the buy-button
|
|
/// to "Already Owned." Returning the global wardrobe here therefore
|
|
/// makes every store item appear owned. We instead intersect the
|
|
/// caller's <see cref="AvatarEntity.InventoryJson"/> with the master
|
|
/// catalog so the watch sees only items the player actually owns.
|
|
///
|
|
/// 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
|
|
/// <c>default_unlocked_variants</c>) in every response. Without them a
|
|
/// default item such as Beard Close showed a price in the outfit menu.
|
|
/// Anonymous calls (<c>defaultunlocked</c> before login) get the defaults.
|
|
/// </summary>
|
|
[ApiController]
|
|
public class AvatarItemsController(
|
|
DorkNetDbContext db,
|
|
IConfiguration config,
|
|
ServerSettingsService serverSettings) : ControllerBase
|
|
{
|
|
/// <summary>Master catalog keyed by GUID. Loaded once on first
|
|
/// request from <c>data/avatar_item_lookup.json</c>. Same
|
|
/// lazy-load idiom as before but the catalog itself is now a
|
|
/// dump of the watch's bundled
|
|
/// <c>avatarItemDataLookup</c> instead of the asset-bundle
|
|
/// MonoBehaviour scan in the previous wardrobe_items.json.</summary>
|
|
private static readonly Lazy<CatalogData> _catalog = new(LoadCatalog);
|
|
|
|
[HttpGet("api/avatar/v2/items")]
|
|
[HttpGet("api/avatar/v3/items")]
|
|
[HttpGet("api/avatar/v4/items")]
|
|
[Authorize]
|
|
public async Task<ActionResult<List<UnlockedAvatarItemDto>>> Get()
|
|
{
|
|
// Keep this switch as a fast kill-switch, but default it on now that
|
|
// the response is bounded below. The risky path is not the parser
|
|
// itself; it is feeding the 2020 watch too many BrowsableAvatarItem /
|
|
// DynamicAvatarItemImposter rows at once.
|
|
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
|
|
return Ok(new List<UnlockedAvatarItemDto>());
|
|
|
|
// The old [1,120] / [1,20] ceilings reflected the 2020 *watch's*
|
|
// render-budget headroom — feeding it too many BrowsableAvatarItem /
|
|
// DynamicAvatarItemImposter rows could crash its wardrobe drawer.
|
|
// This branch (march-2023-03-21) serves the 2023 DESKTOP client, which
|
|
// has no such limit, and the watch-era cap was silently truncating the
|
|
// owned wardrobe to 100 ("most of my items don't show"). Raise the
|
|
// ceilings well above the full catalog (882 base items + variants) so
|
|
// nothing the player owns is dropped; the config keys still let an
|
|
// operator dial it back down for a watch build.
|
|
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 4000), 1, 8000);
|
|
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 1000), 1, 2000);
|
|
|
|
// "Everyone owns all avatar items" admin toggle (mirrors the
|
|
// "everyone is friends" one): report the ENTIRE master catalog plus
|
|
// all permanent hair dyes as owned, for every caller, without reading
|
|
// or writing any inventory row. Flipping the toggle off instantly
|
|
// 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 await WithStoreInfoAsync(BuildEntireCatalogOwned(maxItems));
|
|
|
|
var pid = this.CurrentPlayerId();
|
|
if (pid is not long me) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems));
|
|
|
|
var avatar = await db.Avatars
|
|
.Where(a => a.PlayerId == me)
|
|
.Select(a => new { a.InventoryJson, a.OutfitSelections })
|
|
.FirstOrDefaultAsync();
|
|
|
|
var owned = ParseInventoryEntries(avatar?.InventoryJson);
|
|
var ownedHairDyeSlugs = await db.PlayerInventory
|
|
.Where(i => i.PlayerId == me && i.Quantity > 0)
|
|
.Select(i => i.ItemSlug)
|
|
.ToListAsync();
|
|
var ownedHairDyes = ownedHairDyeSlugs
|
|
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
if (owned.Count == 0 && ownedHairDyes.Count == 0) return await WithStoreInfoAsync(BuildDefaultUnlocked(maxItems));
|
|
|
|
var catalog = _catalog.Value;
|
|
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
|
|
// Recency tiebreaker: GrantWardrobeAsync / gift-consume APPEND new
|
|
// GUIDs to the end of InventoryJson, so the original index is also
|
|
// the purchase order. When per-slot truncation has to drop entries,
|
|
// we want it to drop the OLDEST (starter seed) items rather than the
|
|
// one the user just bought.
|
|
var ordered = owned
|
|
.Select((entry, idx) => (Entry: entry, OriginalIndex: idx))
|
|
.OrderByDescending(x => equipped.Contains(x.Entry.BaseGuid))
|
|
.ThenBy(x => catalog.SlotByGuid.TryGetValue(x.Entry.BaseGuid, out var slot) ? slot : int.MaxValue)
|
|
.ThenByDescending(x => x.OriginalIndex)
|
|
.Select(x => x.Entry);
|
|
|
|
// 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<string>(StringComparer.OrdinalIgnoreCase);
|
|
var perSlot = new Dictionary<int, int>();
|
|
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
|
|
foreach (var entry in ordered)
|
|
{
|
|
if (result.Count >= maxOutfitItems) break;
|
|
// Intersect against the safelist. Items not in the bundled
|
|
// lookup would crash the watch's wardrobe parser, so they're
|
|
// silently dropped here. The catalog is keyed by the BARE GUID,
|
|
// so colored variants (stored as "{guid},{swatch},{mask},") must
|
|
// look up by BaseGuid — keying by the full comma string would
|
|
// 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);
|
|
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
|
|
// the watch renders the variant the player paid for instead of
|
|
// falling back to the default swatch.
|
|
result.Add(string.Equals(entry.FullDesc, entry.BaseGuid, StringComparison.OrdinalIgnoreCase)
|
|
? item
|
|
: new UnlockedAvatarItemDto
|
|
{
|
|
AvatarItemType = item.AvatarItemType,
|
|
AvatarItemDesc = ToWatchDesc(entry.FullDesc),
|
|
// Hash the FULL desc, as the toggle-on path does: a
|
|
// player can own the base item and several colorways of
|
|
// it at once (entries dedupe by FullDesc), and reusing the
|
|
// base id made them collide in the client's id-keyed
|
|
// wardrobe.
|
|
AvatarItemId = StableAvatarItemIdFull(ToWatchDesc(entry.FullDesc)),
|
|
PlatformMask = item.PlatformMask,
|
|
FriendlyName = item.FriendlyName,
|
|
Tooltip = item.Tooltip,
|
|
Rarity = item.Rarity,
|
|
TagList = item.TagList,
|
|
IsBaseAvatarItem = item.IsBaseAvatarItem,
|
|
});
|
|
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;
|
|
if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue;
|
|
StoreService.TryGetHairDyeName(slug, out var friendlyName);
|
|
result.Add(new UnlockedAvatarItemDto
|
|
{
|
|
AvatarItemType = AvatarItemType.HairDye,
|
|
AvatarItemDesc = hairColorGuid,
|
|
AvatarItemId = StableAvatarItemId(hairColorGuid),
|
|
PlatformMask = unchecked((int)0xFFFFFFFF),
|
|
FriendlyName = friendlyName,
|
|
Tooltip = string.Empty,
|
|
Rarity = 0,
|
|
TagList = string.Empty,
|
|
IsBaseAvatarItem = false,
|
|
});
|
|
}
|
|
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
|
|
/// (<c>default_unlocked_variants</c>), capped at <paramref name="maxItems"/>.</summary>
|
|
private static List<UnlockedAvatarItemDto> BuildDefaultUnlocked(int maxItems)
|
|
{
|
|
var catalog = _catalog.Value;
|
|
return catalog.DefaultUnlocked.Take(maxItems).Select(g => catalog.Items[g]).ToList();
|
|
}
|
|
|
|
/// <summary>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
|
|
/// <paramref name="maxItems"/>. Hair dyes are emitted in the same
|
|
/// shape the per-player path uses (AvatarItemType.HairDye, desc =
|
|
/// hair-color GUID) so the wardrobe renders them identically.</summary>
|
|
private static List<UnlockedAvatarItemDto> BuildEntireCatalogOwned(int maxItems)
|
|
{
|
|
var catalog = _catalog.Value;
|
|
var result = new List<UnlockedAvatarItemDto>(Math.Min(maxItems, catalog.Items.Count + 32));
|
|
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
|
|
// without this the "everyone owns everything" wardrobe was missing all
|
|
// ~1.8k colorways — the player didn't own them AND buying one gifted a
|
|
// variant desc ("{guid},{combo},{mask},") that wasn't in this list, so
|
|
// the client's post-consume UnlockAvatarItemAndMarkNew Find() missed
|
|
// and NRE'd (the item vanished on buy). Emit each variant with its
|
|
// verbatim equip descriptor and a UNIQUE id hashed from the FULL
|
|
// descriptor (the base-guid hash would collide with the base item and
|
|
// every sibling colorway).
|
|
foreach (var v in StoreService.ColorVariantCatalog.Values)
|
|
{
|
|
if (result.Count >= maxItems) return result;
|
|
if (string.IsNullOrWhiteSpace(v.descriptor)) continue;
|
|
result.Add(new UnlockedAvatarItemDto
|
|
{
|
|
AvatarItemType = AvatarItemType.Outfit,
|
|
AvatarItemDesc = v.descriptor,
|
|
AvatarItemId = StableAvatarItemIdFull(v.descriptor),
|
|
PlatformMask = unchecked((int)0xFFFFFFFF),
|
|
FriendlyName = v.item_name ?? string.Empty,
|
|
Tooltip = string.Empty,
|
|
Rarity = 0,
|
|
TagList = string.Empty,
|
|
IsBaseAvatarItem = false,
|
|
});
|
|
}
|
|
foreach (var slug in StoreService.AllHairDyeSlugs)
|
|
{
|
|
if (result.Count >= maxItems) break;
|
|
if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue;
|
|
StoreService.TryGetHairDyeName(slug, out var friendlyName);
|
|
result.Add(new UnlockedAvatarItemDto
|
|
{
|
|
AvatarItemType = AvatarItemType.HairDye,
|
|
AvatarItemDesc = hairColorGuid,
|
|
AvatarItemId = StableAvatarItemId(hairColorGuid),
|
|
PlatformMask = unchecked((int)0xFFFFFFFF),
|
|
FriendlyName = friendlyName,
|
|
Tooltip = string.Empty,
|
|
Rarity = 0,
|
|
TagList = string.Empty,
|
|
IsBaseAvatarItem = false,
|
|
});
|
|
}
|
|
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 items = _catalog.Value.Items;
|
|
var rows = BaseAvatarItemIds.Keys
|
|
.Where(items.ContainsKey)
|
|
.Select(g => items[g])
|
|
.ToList();
|
|
return Ok(rows);
|
|
}
|
|
|
|
[HttpGet("api/avatar/v1/defaultunlocked")]
|
|
[AllowAnonymous]
|
|
public Task<ActionResult<List<UnlockedAvatarItemDto>>> DefaultUnlocked() => Get();
|
|
|
|
/// <summary>The set of GUIDs the bundled
|
|
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
|
|
/// contains. Exposed so other controllers (StoreService seed,
|
|
/// PlayerService starter inventory) can validate their seed
|
|
/// data against the same safe list.</summary>
|
|
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
|
|
|
|
/// <summary>Base GUIDs of the free items every player owns
|
|
/// (<c>default_unlocked_variants</c> in avatar_item_lookup.json).</summary>
|
|
public static IReadOnlyList<string> DefaultUnlockedGuids => _catalog.Value.DefaultUnlocked;
|
|
|
|
/// <summary>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
|
|
/// check.</summary>
|
|
public static IReadOnlyDictionary<string, UnlockedAvatarItemDto> Catalog
|
|
=> _catalog.Value.Items;
|
|
|
|
/// <summary>GET <c>api/avatar/v1/lockeditems</c> — metadata for
|
|
/// specific avatar item descriptions the caller does not own. The
|
|
/// store UI calls this with item descs from purchasable rows and
|
|
/// expects the same item-detail shape used by unlocked items,
|
|
/// minus ownership. We resolve against the safe runtime catalog so
|
|
/// unknown GUIDs never reach the client parser.</summary>
|
|
[HttpGet("api/avatar/v1/lockeditems")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> LockedItems()
|
|
{
|
|
var requested = RequestedAvatarItemGuids().ToList();
|
|
if (requested.Count == 0) return Ok(Array.Empty<object>());
|
|
|
|
// When everyone owns everything, nothing is "locked" — the store's
|
|
// owned/locked diff must see an empty locked set so every tile reads
|
|
// as owned, consistent with the full wardrobe returned by Get().
|
|
if (await serverSettings.IsAllAvatarItemsOwnedEnabledAsync())
|
|
return Ok(Array.Empty<object>());
|
|
|
|
var owned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var pid = this.CurrentPlayerId();
|
|
if (pid is long me)
|
|
{
|
|
var invJson = await db.Avatars
|
|
.Where(a => a.PlayerId == me)
|
|
.Select(a => a.InventoryJson)
|
|
.FirstOrDefaultAsync();
|
|
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<IFNGBPJGINL> — the SAME element as
|
|
// v4/items (FFFIMAGLKEG.txt:3222-3465, NBLIDNCEELM), read by
|
|
// CLIANCNHNIE which registers TagList / AvatarItemId /
|
|
// IsBaseAvatarItem too (CLIANCNHNIE.txt). The old 5-key subset left
|
|
// every locked row at AvatarItemId 0 with a null TagList, so ship
|
|
// the full catalog DTO.
|
|
var catalog = _catalog.Value.Items;
|
|
var rows = requested
|
|
.Where(guid => !owned.Contains(guid))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
|
|
.Where(item => item is not null)
|
|
.Select(item => item!)
|
|
.ToList();
|
|
return Ok(await WithStoreInfoAsync(rows));
|
|
}
|
|
|
|
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
|
|
/// Used by <see cref="DorkNet.Server.Services.PlayerService"/> to
|
|
/// build a starter loadout (one per slot) and a starter inventory
|
|
/// (a few per slot).</summary>
|
|
public static IReadOnlyDictionary<int, List<string>> BySlot
|
|
=> _catalog.Value.BySlot;
|
|
|
|
public static bool TryGetOutfitType(string guid, out int outfitType) =>
|
|
_catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType);
|
|
|
|
private IEnumerable<string> RequestedAvatarItemGuids()
|
|
{
|
|
foreach (var pair in Request.Query)
|
|
foreach (var raw in pair.Value)
|
|
foreach (var part in (raw ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
{
|
|
var value = part.Trim();
|
|
if (string.IsNullOrWhiteSpace(value)) continue;
|
|
var comma = value.IndexOf(',');
|
|
if (comma >= 0) value = value[..comma];
|
|
if (Guid.TryParse(value, out _)) yield return value;
|
|
}
|
|
}
|
|
|
|
/// <summary>An owned wardrobe entry: the bare AvatarItem GUID used as
|
|
/// the catalog / slot / equipped key, plus the full RecNet desc exactly
|
|
/// as stored in InventoryJson. For a plain item the two are identical;
|
|
/// for a colored variant the desc is "{guid},{swatch},{mask}," so the
|
|
/// swatch the player paid for survives the round-trip to the wardrobe.</summary>
|
|
private readonly record struct OwnedEntry(string BaseGuid, string FullDesc);
|
|
|
|
/// <summary>Parse InventoryJson into normalized owned entries. Accepts
|
|
/// both the plain JSON string array of descs (the shape
|
|
/// GrantWardrobeAsync / PurchaseAsync write) and the legacy
|
|
/// [{itemId,...}] object shape. Strips a leading "wardrobe-" slug
|
|
/// prefix, derives the bare GUID (substring before the first comma) for
|
|
/// catalog lookups, and keeps the full desc so a colored variant keeps
|
|
/// its swatch. Dedupes by full desc, so multiple colors of the same
|
|
/// base item remain distinct wardrobe tiles while exact duplicates
|
|
/// collapse.</summary>
|
|
private static List<OwnedEntry> ParseInventoryEntries(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
|
|
|
|
var raw = new List<string>();
|
|
try
|
|
{
|
|
var strings = JsonSerializer.Deserialize<List<string>>(json);
|
|
if (strings is not null) raw.AddRange(strings);
|
|
}
|
|
catch { }
|
|
|
|
if (raw.Count == 0)
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
|
|
foreach (var element in doc.RootElement.EnumerateArray())
|
|
{
|
|
if (element.ValueKind == JsonValueKind.String)
|
|
raw.Add(element.GetString() ?? string.Empty);
|
|
else if (element.ValueKind == JsonValueKind.Object &&
|
|
element.TryGetProperty("itemId", out var itemId) &&
|
|
itemId.ValueKind == JsonValueKind.String)
|
|
raw.Add(itemId.GetString() ?? string.Empty);
|
|
}
|
|
}
|
|
catch { return new(); }
|
|
}
|
|
|
|
const string wardrobePrefix = "wardrobe-";
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var result = new List<OwnedEntry>();
|
|
foreach (var entry in raw)
|
|
{
|
|
var value = entry;
|
|
if (string.IsNullOrWhiteSpace(value)) continue;
|
|
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
|
|
value = value[wardrobePrefix.Length..];
|
|
var comma = value.IndexOf(',');
|
|
var baseGuid = comma >= 0 ? value[..comma] : value;
|
|
if (string.IsNullOrWhiteSpace(baseGuid)) continue;
|
|
if (!seen.Add(value)) continue;
|
|
result.Add(new OwnedEntry(baseGuid, value));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>Bare-GUID view of the owned set, for callers that only need
|
|
/// ownership membership (e.g. the locked-items diff), not the swatch.</summary>
|
|
private static List<string> ParseInventory(string? json) =>
|
|
ParseInventoryEntries(json)
|
|
.Select(e => e.BaseGuid)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
/// <summary>Ensure a stored desc carries the ≥4 comma-separated parts
|
|
/// (guid + 3 customizations) that the watch's
|
|
/// <c>AvatarItem.FromRecNetString</c> requires — anything shorter throws
|
|
/// inside the wardrobe parser. Stored colored descs are already
|
|
/// "{guid},{swatch},{mask}," (3 commas); this pads any shorter legacy
|
|
/// value.</summary>
|
|
// A stored colored descriptor is the EXACT RecNet equip string
|
|
// ({itemGuid},{combinationGuid…}) — the client feeds everything after the
|
|
// item GUID into combinationLookup.TryGetValue as an opaque, case-sensitive
|
|
// key, so it must be returned byte-for-byte. Do NOT pad to 3 commas: the
|
|
// 2023 combination model has short-form descriptors ({item},{combo}) whose
|
|
// trailing commas are significant — padding "{item},{combo}" to
|
|
// "{item},{combo},," corrupts the key and the watch fails to resolve the
|
|
// combination (renders a broken/default mesh). Only genuinely bare entries
|
|
// (FullDesc == BaseGuid) get the "{guid},,," base form, and those never
|
|
// reach here (the caller emits the catalog DTO directly for them).
|
|
private static string ToWatchDesc(string storedDesc) => storedDesc;
|
|
|
|
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
|
|
{
|
|
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
if (string.IsNullOrWhiteSpace(outfitSelections)) return result;
|
|
|
|
foreach (var selection in outfitSelections.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
{
|
|
var comma = selection.IndexOf(',');
|
|
var guid = comma >= 0 ? selection[..comma] : selection;
|
|
if (!string.IsNullOrWhiteSpace(guid))
|
|
result.Add(guid.Trim());
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private sealed record CatalogData(
|
|
IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items,
|
|
IReadOnlySet<string> SafeGuids,
|
|
IReadOnlyDictionary<int, List<string>> BySlot,
|
|
IReadOnlyDictionary<string, int> SlotByGuid,
|
|
IReadOnlyList<string> DefaultUnlocked);
|
|
|
|
private static CatalogData LoadCatalog()
|
|
{
|
|
// Try both lowercase 'data/' (csproj None+Link target) and
|
|
// uppercase 'Data/' (git-tracked source folder) so case-sensitivity
|
|
// mismatches between Windows (where the dev wrote the path) and
|
|
// Linux (where Coolify builds the container) can't lose the lookup
|
|
// file. Without this fallback, a single missed file → empty
|
|
// SafeGuids → StoreService.SeedAsync skips its stale-row cleanup
|
|
// → 418 swatch-GUID rows ship live → every store tile renders as
|
|
// Hat_Angler.
|
|
var candidates = new[]
|
|
{
|
|
Path.Combine(AppContext.BaseDirectory, "data", "avatar_item_lookup.json"),
|
|
Path.Combine(AppContext.BaseDirectory, "Data", "avatar_item_lookup.json"),
|
|
Path.Combine(Directory.GetCurrentDirectory(), "data", "avatar_item_lookup.json"),
|
|
Path.Combine(Directory.GetCurrentDirectory(), "Data", "avatar_item_lookup.json"),
|
|
};
|
|
var path = candidates.FirstOrDefault(System.IO.File.Exists);
|
|
Console.WriteLine(path is null
|
|
? $"[avatar-catalog] avatar_item_lookup.json not found in any of: {string.Join(", ", candidates)}"
|
|
: $"[avatar-catalog] loading from {path}");
|
|
if (path is null) return new CatalogData(
|
|
new Dictionary<string, UnlockedAvatarItemDto>(),
|
|
new HashSet<string>(),
|
|
new Dictionary<int, List<string>>(),
|
|
new Dictionary<string, int>(),
|
|
Array.Empty<string>());
|
|
|
|
using var fs = System.IO.File.OpenRead(path);
|
|
var dump = JsonSerializer.Deserialize<LookupFile>(fs,
|
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
|
?? new LookupFile();
|
|
|
|
var items = new Dictionary<string, UnlockedAvatarItemDto>(dump.Items.Count, StringComparer.OrdinalIgnoreCase);
|
|
var safe = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var bySlot = new Dictionary<int, List<string>>();
|
|
var slotByGuid = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var row in dump.Items)
|
|
{
|
|
if (string.IsNullOrEmpty(row.Guid)) continue;
|
|
safe.Add(row.Guid);
|
|
// Prefer the baked display_name from the JSON if present
|
|
// (tools/add-display-names.py writes it once per refresh).
|
|
// Falls back to the raw-name cleanup if the field is missing
|
|
// — e.g. partial dumps or older fixture files.
|
|
var pretty = !string.IsNullOrEmpty(row.DisplayName)
|
|
? row.DisplayName
|
|
: PrettyName(row.Name);
|
|
items[row.Guid] = new UnlockedAvatarItemDto
|
|
{
|
|
// AvatarItemType is the wire enum the watch parses
|
|
// (Outfit=0 / HairDye=1), not the slot. Rows in
|
|
// avatarItemDataLookup are wearable avatar items, including
|
|
// hair meshes that *use* hair color. They must still be sent
|
|
// as Outfit; sending HairDye makes the client treat the item
|
|
// GUID as a color GUID and log "Can not find hair dye color".
|
|
AvatarItemType = AvatarItemType.Outfit,
|
|
AvatarItemDesc = 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 = IsBaseAvatarItemGuid(row.Guid),
|
|
};
|
|
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
|
|
bySlot[row.OutfitType] = lst = new List<string>();
|
|
lst.Add(row.Guid);
|
|
slotByGuid[row.Guid] = row.OutfitType;
|
|
}
|
|
|
|
// 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) && !IsBaseAvatarItemGuid(g))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
return new CatalogData(items, safe, bySlot, slotByGuid, defaults);
|
|
}
|
|
|
|
/// <summary>Stable, deterministic, non-zero 31-bit id for an owned
|
|
/// avatar item, derived from its base GUID (FNV-1a). The 2023 client
|
|
/// keys the wardrobe by <c>AvatarItemId</c>, so it must be unique per
|
|
/// item and stable across restarts. Colored variants must NOT use this — a player can own the base and
|
|
/// several colorways together; see <see cref="StableAvatarItemIdFull"/>.</summary>
|
|
public static int StableAvatarItemId(string? avatarItemDescOrGuid)
|
|
{
|
|
var guid = (avatarItemDescOrGuid ?? string.Empty).Split(',')[0];
|
|
uint h = 2166136261u;
|
|
foreach (var c in guid) h = (h ^ c) * 16777619u;
|
|
var id = (int)(h & 0x7FFFFFFF);
|
|
return id == 0 ? 1 : id;
|
|
}
|
|
|
|
/// <summary>Stable id from the FULL descriptor string (not just the base
|
|
/// guid). Colored variants share a base guid but are distinct wardrobe
|
|
/// entries, so they must hash the whole "{guid},{combo},{mask}," string
|
|
/// to get ids distinct from the base item and each other.</summary>
|
|
public static int StableAvatarItemIdFull(string? descriptor)
|
|
{
|
|
uint h = 2166136261u;
|
|
foreach (var c in descriptor ?? string.Empty) h = (h ^ c) * 16777619u;
|
|
var id = (int)(h & 0x7FFFFFFF);
|
|
return id == 0 ? 1 : id;
|
|
}
|
|
|
|
private static string PrettyName(string raw)
|
|
{
|
|
if (string.IsNullOrEmpty(raw)) return string.Empty;
|
|
// Strip leading/trailing parens that the bundled config wraps
|
|
// names in, then turn underscores into spaces. Keep it simple —
|
|
// the watch shows whatever string we send.
|
|
var trimmed = raw.Trim().TrimStart('(').TrimEnd(')');
|
|
// Drop common asset-name suffixes. Items in the runtime config
|
|
// use the raw asset name (e.g. "Hat_Wizard", "shirt_sherlock");
|
|
// the watch never normalises these, so we do it server-side.
|
|
var s = trimmed.Replace('_', ' ').Trim();
|
|
return s;
|
|
}
|
|
|
|
/// <summary>Shape of <c>data/avatar_item_lookup.json</c> as written
|
|
/// by <c>tools/AvatarCatalogDumpPatch</c>.</summary>
|
|
private sealed class LookupFile
|
|
{
|
|
[JsonPropertyName("items")]
|
|
public List<LookupItem> Items { get; set; } = new();
|
|
|
|
// Auxiliary GUID lists (prefab/mask/swatch/decal). Not used
|
|
// server-side yet — they'd matter for resolving asset
|
|
// references the watch might subsequently request, but the
|
|
// wire protocol only ever ships the AvatarItem GUID.
|
|
[JsonPropertyName("prefab_guids")]
|
|
public List<string> PrefabGuids { get; set; } = new();
|
|
[JsonPropertyName("mask_guids")]
|
|
public List<string> MaskGuids { get; set; } = new();
|
|
[JsonPropertyName("swatch_guids")]
|
|
public List<string> SwatchGuids { get; set; } = new();
|
|
[JsonPropertyName("decal_guids")]
|
|
public List<string> DecalGuids { get; set; } = new();
|
|
|
|
// The client config's defaultUnlockedVariants: free items every
|
|
// player owns.
|
|
[JsonPropertyName("default_unlocked_variants")]
|
|
public List<LookupVariant> 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
|
|
{
|
|
[JsonPropertyName("guid")] public string Guid { get; set; } = string.Empty;
|
|
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
|
|
// Algorithmically derived from `name` by
|
|
// tools/add-display-names.py — Rec Room's official UI strings
|
|
// live in Unity asset bundles we can't read from a code dump,
|
|
// so this is a best-effort cleanup. Optional — server falls
|
|
// back to PrettyName() if missing.
|
|
[JsonPropertyName("display_name")] public string? DisplayName { get; set; }
|
|
[JsonPropertyName("outfit_type")] public int OutfitType { get; set; }
|
|
[JsonPropertyName("outfit_set")] public int OutfitSet { get; set; }
|
|
[JsonPropertyName("drawer_index")] public int DrawerIndex { get; set; }
|
|
[JsonPropertyName("hides_hair")] public bool HidesHair { get; set; }
|
|
[JsonPropertyName("hides_hand")] public bool HidesHand { get; set; }
|
|
// hides_left_ear / hides_right_ear were added to AvatarItemData
|
|
// in the 2020.12.18 client build. Optional in the JSON so older
|
|
// dumps still deserialize.
|
|
[JsonPropertyName("hides_left_ear")] public bool HidesLeftEar { get; set; }
|
|
[JsonPropertyName("hides_right_ear")] public bool HidesRightEar { get; set; }
|
|
[JsonPropertyName("uses_hair_color")] public bool UsesHairColor { get; set; }
|
|
[JsonPropertyName("uses_team_color")] public bool UsesTeamColor { get; set; }
|
|
[JsonPropertyName("is_team_item")] public bool IsTeamItem { get; set; }
|
|
}
|
|
|
|
/// <summary>Mirrors <c>RecNet.Avatars+AvatarItemType</c>
|
|
/// (Avatars.cs:186-190).</summary>
|
|
private static class AvatarItemType
|
|
{
|
|
public const int Outfit = 0;
|
|
public const int HairDye = 1;
|
|
}
|
|
|
|
/// <summary>OutfitType slot enum mirrored from
|
|
/// <c>RecRoom.Avatar.Data.Shared.OutfitType</c>. Used by other
|
|
/// callers (PlayerService, StoreService) for slot-aware seeding.</summary>
|
|
public static class Slot
|
|
{
|
|
public const int Hat = 0;
|
|
public const int BackHead = 1;
|
|
public const int Hair = 2;
|
|
public const int Eye = 10;
|
|
public const int Mouth = 20;
|
|
public const int Neck = 100;
|
|
public const int Shirt = 101;
|
|
public const int Belt = 102;
|
|
public const int Pocket = 103;
|
|
public const int TeamShirt = 104;
|
|
public const int Wrist = 200;
|
|
public const int Glove = 201;
|
|
public const int Watch = 202;
|
|
public const int TeamWrist = 203;
|
|
}
|
|
}
|
|
|
|
public class UnlockedAvatarItemDto
|
|
{
|
|
[JsonPropertyName("AvatarItemType")]
|
|
public int AvatarItemType { get; set; }
|
|
|
|
[JsonPropertyName("AvatarItemDesc")]
|
|
public string AvatarItemDesc { get; set; } = string.Empty;
|
|
|
|
/// <summary>The 2023 client's owned-item DTO (RecNet IFNGBPJGINL) reads
|
|
/// <c>AvatarItemId</c> as an <b>Int32</b>. Omitting it left every owned
|
|
/// item at id 0; the client keys its wardrobe by AvatarItemId, so a
|
|
/// second item collided and the whole unlocked-items list failed to
|
|
/// build — the wardrobe showed nothing owned and buying (which refetches
|
|
/// this list) NRE'd in OutfitManager.UnlockAvatarItemAndMarkNew. Emit a
|
|
/// stable, unique, non-zero id.</summary>
|
|
[JsonPropertyName("AvatarItemId")]
|
|
public int AvatarItemId { get; set; }
|
|
|
|
[JsonPropertyName("PlatformMask")]
|
|
public int PlatformMask { get; set; }
|
|
|
|
[JsonPropertyName("FriendlyName")]
|
|
public string FriendlyName { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("Tooltip")]
|
|
public string Tooltip { get; set; } = string.Empty;
|
|
|
|
[JsonPropertyName("Rarity")]
|
|
public int Rarity { get; set; }
|
|
|
|
/// <summary>CSV tag string (IFNGBPJGINL.TagList). Empty is fine —
|
|
/// AvatarItemMetadata.ParseTagList tolerates it — but the key must be
|
|
/// present and a string, never null.</summary>
|
|
[JsonPropertyName("TagList")]
|
|
public string TagList { get; set; } = string.Empty;
|
|
|
|
/// <summary>Whether this is a default base-body item vs a cosmetic
|
|
/// (IFNGBPJGINL.IsBaseAvatarItem). False for wardrobe/catalog items.</summary>
|
|
[JsonPropertyName("IsBaseAvatarItem")]
|
|
public bool IsBaseAvatarItem { get; set; }
|
|
}
|