avatar: raise unlocked-items defaults so new purchases survive relog

/api/avatar/v4/items truncated owned items by maxItems (was 30) and
maxPerSlot (was 4). The per-slot cap silently dropped any newly-bought
wardrobe item once a slot held 4+ entries — the 'I bought it but it's
gone after relog' symptom. Defaults raised to 100 / 20.

Clamp ranges left at [1,120] / [1,20] (prior maintainer note locked
these to watch render-budget testing — raising the ceiling without
retesting risks BrowsableAvatarItem / DynamicAvatarItemImposter
thrash inside the watch's wardrobe drawer).

Sort tiebreaker added: ThenByDescending(OriginalIndex) so when
truncation has to drop entries, it drops the OLDEST (starter seed)
items rather than the just-purchased one. GrantWardrobeAsync and
the gift-consume path both append new GUIDs to the end of
InventoryJson, so list index == purchase order.
This commit is contained in:
Alexa
2026-05-27 10:43:44 +03:00
parent f5061f014b
commit f331530974
@@ -85,8 +85,16 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
return Ok(new List<UnlockedAvatarItemDto>());
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 30), 1, 120);
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 4), 1, 20);
// Defaults raised (30 → 100, 4 → 20) — the old 4-per-slot silently
// dropped any newly-purchased item once a slot held 4+ entries (the
// "I bought it but it's gone after relog" symptom). The clamp
// ranges stay at [1,120] / [1,20] — those reflect empirical watch
// testing on render-budget headroom (per the prior maintainer note
// about BrowsableAvatarItem / DynamicAvatarItemImposter thrash
// above this count); raising the clamp ceiling without retesting
// could crash the watch's wardrobe drawer.
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 100), 1, 120);
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 20), 1, 20);
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
@@ -109,10 +117,17 @@ public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) :
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
.OrderByDescending(guid => equipped.Contains(guid))
.ThenBy(guid => catalog.SlotByGuid.TryGetValue(guid, out var slot) ? slot : int.MaxValue)
.ThenBy(guid => guid, StringComparer.OrdinalIgnoreCase);
.Select((guid, idx) => (Guid: guid, OriginalIndex: idx))
.OrderByDescending(x => equipped.Contains(x.Guid))
.ThenBy(x => catalog.SlotByGuid.TryGetValue(x.Guid, out var slot) ? slot : int.MaxValue)
.ThenByDescending(x => x.OriginalIndex)
.Select(x => x.Guid);
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
var perSlot = new Dictionary<int, int>();