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;
///
/// api.rec.net/api/avatar/v{2,3,4}/items — list of avatar items the
/// caller has UNLOCKED. The watch's
/// RecNet.Avatars+<DowloadUnlockedAvatarItems> coroutine
/// (Cpp2IL_ISIL/.../Avatars_NestedType__DowloadUnlockedAvatarItems_d__21.txt:149
/// hits "{api/avatar/}v4/items") parses the result as
/// List<UnlockedAvatarItem>; 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 — AvatarItemDesc format: the client passes this
/// string to AvatarItem.FromRecNetString(string)
/// (Cpp2IL_ISIL/.../RecRoom/Avatar/Data/Runtime/AvatarItem.txt:81-257).
/// The desc is String.Split(',') into guid + customization[0..3]
/// — the inner overload then indexes customization[0],
/// customization[1], customization[2] and throws
/// ArgumentException at ArraySegment.get_Current 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
/// ",,," (three empty customizations).
///
/// CATALOG SOURCE — data/avatar_item_lookup.json. Generated by
/// the manual/CI-only BepInEx patch under
/// tools/AvatarCatalogDumpPatch. That patch reads the watch's bundled
/// AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup
/// SerializedDictionary at boot and writes every (guid, AvatarItemData)
/// pair to JSON. THIS is the authoritative safe list — any GUID NOT
/// in this file throws InvalidOperationException("Missing data
/// for guid …") inside the watch's wardrobe parser, which aborts
/// the entire coroutine on the first bad item and leaves the drawer
/// empty. Items extracted from outfits_assets_*.bundle
/// MonoBehaviours (the older data/wardrobe_items.json) 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
/// AvatarItemDesc 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 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
/// default_unlocked_variants) in every response. Without them a
/// default item such as Beard Close showed a price in the outfit menu.
/// Anonymous calls (defaultunlocked before login) get the defaults.
///
[ApiController]
public class AvatarItemsController(
DorkNetDbContext db,
IConfiguration config,
ServerSettingsService serverSettings) : ControllerBase
{
/// Master catalog keyed by GUID. Loaded once on first
/// request from data/avatar_item_lookup.json. Same
/// lazy-load idiom as before but the catalog itself is now a
/// dump of the watch's bundled
/// avatarItemDataLookup instead of the asset-bundle
/// MonoBehaviour scan in the previous wardrobe_items.json.
private static readonly Lazy _catalog = new(LoadCatalog);
[HttpGet("api/avatar/v2/items")]
[HttpGet("api/avatar/v3/items")]
[HttpGet("api/avatar/v4/items")]
[Authorize]
public async Task>> 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());
// 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 Ok(BuildEntireCatalogOwned(maxItems));
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(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 Ok(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(StringComparer.OrdinalIgnoreCase);
var perSlot = new Dictionary();
var result = new List(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 Ok(result);
}
/// The free items every player owns
/// (default_unlocked_variants), capped at .
private static List BuildDefaultUnlocked(int maxItems)
{
var catalog = _catalog.Value;
return catalog.DefaultUnlocked.Take(maxItems).Select(g => catalog.Items[g]).ToList();
}
/// 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
/// . 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.
private static List BuildEntireCatalogOwned(int maxItems)
{
var catalog = _catalog.Value;
var result = new List(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>> DefaultUnlocked() => Get();
/// The set of GUIDs the bundled
/// AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup
/// contains. Exposed so other controllers (StoreService seed,
/// PlayerService starter inventory) can validate their seed
/// data against the same safe list.
public static IReadOnlySet SafeGuids => _catalog.Value.SafeGuids;
/// Base GUIDs of the free items every player owns
/// (default_unlocked_variants in avatar_item_lookup.json).
public static IReadOnlyList DefaultUnlockedGuids => _catalog.Value.DefaultUnlocked;
/// 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.
public static IReadOnlyDictionary Catalog
=> _catalog.Value.Items;
/// GET api/avatar/v1/lockeditems — 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.
[HttpGet("api/avatar/v1/lockeditems")]
[AllowAnonymous]
public async Task LockedItems()
{
var requested = RequestedAvatarItemGuids().ToList();
if (requested.Count == 0) return Ok(Array.Empty