mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
ee7d614262
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.
806 lines
39 KiB
C#
806 lines
39 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;
|
|
|
|
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;
|
|
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;
|
|
}
|
|
|
|
[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)
|
|
.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 = StableAvatarItemId(row.Guid),
|
|
PlatformMask = unchecked((int)0xFFFFFFFF),
|
|
FriendlyName = pretty,
|
|
Tooltip = string.Empty,
|
|
Rarity = 0,
|
|
TagList = string.Empty,
|
|
IsBaseAvatarItem = false,
|
|
};
|
|
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))
|
|
.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; }
|
|
}
|